공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
241 lines
9.0 KiB
Python
241 lines
9.0 KiB
Python
"""금산군 Phase 1 — 사이트맵 → 엑셀 (D~K + 병합·정렬·스타일).
|
|
|
|
매뉴얼: D:\\01.프로젝트\\DB수집\\사이트맵_수집_매뉴얼.md
|
|
사이트맵 패턴: 계룡시형 (.sitemap > dl > dt|dd > ul > li, 중첩 ul.s_4th_ul)
|
|
"""
|
|
import shutil
|
|
import warnings
|
|
from copy import copy
|
|
from urllib.parse import urljoin
|
|
|
|
import openpyxl
|
|
import requests
|
|
from bs4 import BeautifulSoup
|
|
from openpyxl.styles import Alignment, Font
|
|
|
|
warnings.filterwarnings('ignore')
|
|
|
|
INSTITUTION = '금산군'
|
|
BASE = 'https://www.geumsan.go.kr'
|
|
SITEMAP_URL = 'https://www.geumsan.go.kr/kr/html/sub07/0701.html'
|
|
SHEET_NAME = '03_금산군'
|
|
TEMPLATE = r'D:\01.프로젝트\DB수집\자료_취합_예시.xlsx'
|
|
OUTPUT = r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\3.금산군\충청남도_금산군.xlsx'
|
|
|
|
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}
|
|
|
|
|
|
def extract_href(a):
|
|
if a is None:
|
|
return ''
|
|
href = (a.get('href') or '').strip()
|
|
if not href or href.startswith('#') or href.lower().startswith('javascript:'):
|
|
return ''
|
|
return href
|
|
|
|
|
|
def abs_url(href):
|
|
if not href:
|
|
return ''
|
|
href = href.strip()
|
|
if href.startswith(('javascript:', '#')):
|
|
return ''
|
|
if href.startswith(('http://', 'https://')):
|
|
return href
|
|
return urljoin(BASE + '/', href)
|
|
|
|
|
|
def is_external(url):
|
|
return url.startswith(('http://', 'https://')) and 'geumsan.go.kr' not in url
|
|
|
|
|
|
def walk_ul(ul, depth, base_path, out):
|
|
for li in ul.find_all('li', recursive=False):
|
|
a = li.find('a', recursive=False)
|
|
if not a:
|
|
continue
|
|
text = a.get_text(strip=True).replace('\xa0', '').strip()
|
|
href = extract_href(a)
|
|
path = base_path[:depth] + [(text, href)]
|
|
nested = li.find('ul', recursive=False)
|
|
if nested:
|
|
out.append({'path': list(path), 'href': href})
|
|
walk_ul(nested, depth + 1, path, out)
|
|
else:
|
|
out.append({'path': list(path), 'href': href})
|
|
|
|
|
|
def main():
|
|
print('[1] 사이트맵 가져오기')
|
|
r = requests.get(SITEMAP_URL, headers=H, timeout=20, verify=False)
|
|
r.encoding = r.apparent_encoding
|
|
soup = BeautifulSoup(r.text, 'html.parser')
|
|
|
|
sitemap = soup.select_one('.sitemap')
|
|
if sitemap is None:
|
|
raise RuntimeError('사이트맵(.sitemap) 영역을 찾지 못했습니다.')
|
|
dls = [dl for dl in sitemap.find_all('dl', recursive=False) if dl.find('dt') and dl.find('dd')]
|
|
print(f' 대분류 dl: {len(dls)}개')
|
|
|
|
raw_rows = []
|
|
for dl in dls:
|
|
dt = dl.find('dt')
|
|
# dt 자체가 텍스트이거나 dt > a 둘 다 처리
|
|
dt_a = dt.find('a') if dt else None
|
|
menu_name = (dt_a.get_text(strip=True) if dt_a else (dt.get_text(strip=True) if dt else ''))
|
|
for dd in dl.find_all('dd', recursive=False):
|
|
b = dd.find('b')
|
|
b_a = b.find('a') if b else None
|
|
mid_name = b_a.get_text(strip=True) if b_a else ''
|
|
mid_href = extract_href(b_a) if b_a else ''
|
|
nested = dd.find('ul', recursive=False)
|
|
if nested:
|
|
tmp = []
|
|
walk_ul(nested, 0, [], tmp)
|
|
for item in tmp:
|
|
p = item['path']
|
|
row = {'D': menu_name, 'E': mid_name, 'href': item['href'],
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}
|
|
for di, (t, _) in enumerate(p):
|
|
col = 'FGHIJ'[di] if di < 5 else 'J'
|
|
row[col] = t
|
|
raw_rows.append(row)
|
|
else:
|
|
raw_rows.append({'D': menu_name, 'E': mid_name, 'href': mid_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
|
|
print(f'[2] 원본 행: {len(raw_rows)}')
|
|
|
|
# 부모-자식 URL 중복 제거
|
|
final_rows = []
|
|
i = 0
|
|
removed = 0
|
|
while i < len(raw_rows):
|
|
row = raw_rows[i]
|
|
if (i + 1 < len(raw_rows)
|
|
and row.get('G', '') == ''
|
|
and raw_rows[i + 1].get('D') == row.get('D')
|
|
and raw_rows[i + 1].get('E') == row.get('E')
|
|
and raw_rows[i + 1].get('F') == row.get('F')
|
|
and raw_rows[i + 1].get('G', '') != ''
|
|
and raw_rows[i + 1].get('href') == row.get('href')):
|
|
removed += 1
|
|
i += 1
|
|
continue
|
|
final_rows.append(row)
|
|
i += 1
|
|
print(f'[3] 부모-자식 URL 중복 삭제: {removed}개 → 최종 {len(final_rows)}행')
|
|
|
|
print('[4] 엑셀 템플릿 복사')
|
|
shutil.copy(TEMPLATE, OUTPUT)
|
|
wb = openpyxl.load_workbook(OUTPUT)
|
|
ws = wb.active
|
|
ws.title = SHEET_NAME
|
|
|
|
HEADER = {'B1:R1', 'S1:W1', 'Y1:AA1'}
|
|
for rng in [str(m) for m in ws.merged_cells.ranges if str(m) not in HEADER]:
|
|
ws.unmerge_cells(rng)
|
|
for row in ws.iter_rows(min_row=3, max_row=ws.max_row, min_col=1, max_col=ws.max_column):
|
|
for cell in row:
|
|
cell.value = None
|
|
|
|
START = 3
|
|
template_r = 3
|
|
cur_max = ws.max_row
|
|
for idx, item in enumerate(final_rows, start=START):
|
|
if idx > cur_max:
|
|
for c in range(1, ws.max_column + 1):
|
|
srcc = ws.cell(template_r, c)
|
|
tgt = ws.cell(idx, c)
|
|
if srcc.has_style:
|
|
tgt.font = copy(srcc.font)
|
|
tgt.fill = copy(srcc.fill)
|
|
tgt.border = copy(srcc.border)
|
|
tgt.alignment = copy(srcc.alignment)
|
|
tgt.number_format = srcc.number_format
|
|
tgt.protection = copy(srcc.protection)
|
|
url = abs_url(item.get('href', ''))
|
|
ws.cell(idx, 2).value = idx - 2
|
|
ws.cell(idx, 3).value = INSTITUTION
|
|
ws.cell(idx, 4).value = item.get('D', '')
|
|
ws.cell(idx, 5).value = item.get('E', '')
|
|
ws.cell(idx, 6).value = item.get('F', '')
|
|
ws.cell(idx, 7).value = item.get('G', '')
|
|
ws.cell(idx, 8).value = item.get('H', '')
|
|
ws.cell(idx, 9).value = item.get('I', '')
|
|
ws.cell(idx, 10).value = item.get('J', '')
|
|
ws.cell(idx, 11).value = url
|
|
if is_external(url):
|
|
ws.cell(idx, 19).value = '외부링크'
|
|
|
|
END = START + len(final_rows) - 1
|
|
print(f'[5] 데이터 기입: {START}~{END}')
|
|
|
|
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=()):
|
|
runs = []
|
|
cur_val = ws.cell(START, col_idx).value
|
|
cur_grp = tuple(ws.cell(START, g).value for g in group_cols)
|
|
run_start = START
|
|
for r in range(START + 1, END + 1):
|
|
v = ws.cell(r, col_idx).value
|
|
g = tuple(ws.cell(r, gg).value for gg in group_cols)
|
|
if v == cur_val and g == 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 = v, g, 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
|
|
return len(runs)
|
|
|
|
# 병합 순서 F→E→D (D를 먼저 병합하면 2행부터 D=None이 되어 E 그룹키가 깨짐)
|
|
n_f = merge_runs('F', 6, group_cols=(4, 5))
|
|
n_e = merge_runs('E', 5, group_cols=(4,))
|
|
n_d = 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
|
|
print(f'[6] 병합 — D:{n_d} E:{n_e} F:{n_f}')
|
|
|
|
for r in range(1, END + 1):
|
|
ws.row_dimensions[r].height = 15
|
|
|
|
link_n = 0
|
|
for r in range(START, END + 1):
|
|
cell = ws.cell(r, 11)
|
|
u = cell.value
|
|
if u and 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
|
|
link_n += 1
|
|
print(f'[7] 행 높이 15 + K 하이퍼링크 {link_n}개 (좌측 정렬)')
|
|
|
|
wb.save(OUTPUT)
|
|
print(f'[8] 저장: {OUTPUT}')
|
|
|
|
# 통계
|
|
print('\n=== 통계 ===')
|
|
from collections import Counter
|
|
cnt = Counter(item.get('D') for item in final_rows)
|
|
print('대분류별 행수:')
|
|
for k, v in cnt.items():
|
|
print(f' {k}: {v}')
|
|
ext_n = sum(1 for it in final_rows if is_external(abs_url(it.get('href', ''))))
|
|
print(f'외부링크: {ext_n}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|