공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
735 lines
32 KiB
Python
735 lines
32 KiB
Python
"""충청남도 11개 시·군 Phase 1 일괄 처리.
|
|
|
|
매뉴얼: D:\\01.프로젝트\\DB수집\\사이트맵_수집_매뉴얼.md
|
|
출력: 각 폴더의 {기관명}.xlsx
|
|
"""
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
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')
|
|
|
|
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}
|
|
TEMPLATE = r'D:\01.프로젝트\DB수집\자료_취합_예시.xlsx'
|
|
|
|
# ================================================================
|
|
# 공통 유틸
|
|
# ================================================================
|
|
|
|
|
|
def fetch_html(url, timeout=20):
|
|
r = requests.get(url, headers=H, timeout=timeout, verify=False, allow_redirects=True)
|
|
r.encoding = r.apparent_encoding
|
|
return r.text
|
|
|
|
|
|
def clean_text(s):
|
|
return re.sub(r'\s+', ' ', s or '').strip().replace('\xa0', '')
|
|
|
|
|
|
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:'):
|
|
# Try onclick encodeURI extraction
|
|
return ''
|
|
return href
|
|
|
|
|
|
ENCODE_URI_PAT = re.compile(r"""(?:location\.href|window\.location(?:\.href)?)\s*=\s*encodeURI\(\s*['"]([^'"]+)['"]\s*\)""")
|
|
ONCLICK_HREF_PAT = re.compile(r"""(?:location\.href|window\.open)\s*\(?\s*['"]([^'"]+)['"]""")
|
|
|
|
|
|
def extract_href_with_onclick(a):
|
|
"""Extract href; if href is dummy (#...), check onclick for encodeURI/location.href."""
|
|
if a is None:
|
|
return ''
|
|
href = (a.get('href') or '').strip()
|
|
if href and not href.startswith('#') and not href.lower().startswith('javascript:'):
|
|
return href
|
|
# Try onclick
|
|
onclick = a.get('onclick', '')
|
|
if onclick:
|
|
m = ENCODE_URI_PAT.search(onclick)
|
|
if m:
|
|
return m.group(1)
|
|
m = ONCLICK_HREF_PAT.search(onclick)
|
|
if m:
|
|
return m.group(1)
|
|
return ''
|
|
|
|
|
|
# ================================================================
|
|
# 사이트별 파서 (각각 raw_rows 리스트 반환)
|
|
# raw_rows: [{'D': str, 'E': str, 'F': str, 'G': str, 'H': str, 'I': str, 'J': str, 'href': str}, ...]
|
|
# ================================================================
|
|
|
|
|
|
def parse_eGov_type1(soup, base):
|
|
"""논산시·아산시 형: div.sitemap.type1 > [div.s_1th + div.inner > div.s_2th + ul...]."""
|
|
sitemap = soup.select_one('div.sitemap.type1') or soup.select_one('div.sitemap')
|
|
rows = []
|
|
if not sitemap:
|
|
return rows
|
|
current_D = ''
|
|
|
|
def walk(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 = clean_text(a.get_text())
|
|
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(nested, depth + 1, path, out)
|
|
else:
|
|
out.append({'path': list(path), 'href': href})
|
|
|
|
for child in sitemap.find_all('div', recursive=False):
|
|
cls = child.get('class', [])
|
|
if 's_1th' in cls:
|
|
a = child.find('a')
|
|
current_D = clean_text(a.get_text()) if a else ''
|
|
elif 'inner' in cls:
|
|
s2 = child.find('div', class_='s_2th')
|
|
mid_a = s2.find('a') if s2 else None
|
|
mid_name = clean_text(mid_a.get_text()) if mid_a else ''
|
|
mid_href = extract_href(mid_a) if mid_a else ''
|
|
uls = child.find_all('ul', recursive=False)
|
|
if not uls:
|
|
rows.append({'D': current_D, 'E': mid_name, 'href': mid_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
for ul in uls:
|
|
tmp = []
|
|
walk(ul, 0, [], tmp)
|
|
for item in tmp:
|
|
p = item['path']
|
|
row = {'D': current_D, '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
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def parse_amThum(soup, base):
|
|
"""당진시·태안군 형: div.amThum > h2.siteNN + div.sitemap_grep > ul.sitemap_list > li > a.first + ul > li > a (+ ul > li > a)."""
|
|
rows = []
|
|
for amthum in soup.select('div.amThum'):
|
|
h2 = amthum.find(['h2', 'h3'], class_=re.compile(r'site\d+'))
|
|
D = clean_text(h2.find('span').get_text()) if h2 and h2.find('span') else (clean_text(h2.get_text()) if h2 else '')
|
|
grep = amthum.find('div', class_='sitemap_grep') or amthum
|
|
for sl in grep.find_all('ul', class_='sitemap_list'):
|
|
# Each ul.sitemap_list contains li > a.first + ul > li > a
|
|
for top_li in sl.find_all('li', recursive=False):
|
|
a_first = top_li.find('a', class_='first', recursive=False)
|
|
if not a_first:
|
|
continue
|
|
E = clean_text(a_first.get_text())
|
|
E_href = extract_href(a_first)
|
|
inner_ul = top_li.find('ul', recursive=False)
|
|
if not inner_ul:
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
for mid_li in inner_ul.find_all('li', recursive=False):
|
|
mid_a = mid_li.find('a', recursive=False)
|
|
if not mid_a:
|
|
continue
|
|
F = clean_text(mid_a.get_text())
|
|
F_href = extract_href(mid_a)
|
|
deeper = mid_li.find('ul', recursive=False)
|
|
if not deeper:
|
|
rows.append({'D': D, 'E': E, 'F': F, 'href': F_href,
|
|
'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
rows.append({'D': D, 'E': E, 'F': F, 'href': F_href,
|
|
'G': '', 'H': '', 'I': '', 'J': ''})
|
|
for deep_li in deeper.find_all('li', recursive=False):
|
|
deep_a = deep_li.find('a', recursive=False)
|
|
if not deep_a:
|
|
continue
|
|
G = clean_text(deep_a.get_text())
|
|
rows.append({'D': D, 'E': E, 'F': F, 'G': G,
|
|
'href': extract_href(deep_a),
|
|
'H': '', 'I': '', 'J': ''})
|
|
return rows
|
|
|
|
|
|
def parse_ul_sitemap_h4(soup, base):
|
|
"""보령시·서천군 형: ul.sitemap > li (대분류) > h4.siteNN > span + ul > li > h5 > a + ul > li.list > a."""
|
|
rows = []
|
|
container = soup.select_one('ul.sitemap') or soup.select_one('div#contents ul.sitemap')
|
|
if not container:
|
|
return rows
|
|
for top_li in container.find_all('li', recursive=False):
|
|
h4 = top_li.find(['h4', 'h3'], recursive=False)
|
|
D = ''
|
|
if h4:
|
|
span = h4.find('span')
|
|
D = clean_text(span.get_text() if span else h4.get_text())
|
|
# Each direct ul under top_li is a sub-group
|
|
for sub_ul in top_li.find_all('ul', recursive=False):
|
|
for sub_li in sub_ul.find_all('li', recursive=False):
|
|
h5 = sub_li.find(['h5', 'h6'], recursive=False)
|
|
if h5:
|
|
h5_a = h5.find('a')
|
|
E = clean_text(h5_a.get_text()) if h5_a else clean_text(h5.get_text())
|
|
E_href = extract_href(h5_a) if h5_a else ''
|
|
else:
|
|
a = sub_li.find('a', recursive=False)
|
|
E = clean_text(a.get_text()) if a else ''
|
|
E_href = extract_href(a) if a else ''
|
|
deeper = sub_li.find('ul', recursive=False)
|
|
if not deeper:
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
for leaf_li in deeper.find_all('li', recursive=False):
|
|
leaf_a = leaf_li.find('a', recursive=False)
|
|
if not leaf_a:
|
|
continue
|
|
F = clean_text(leaf_a.get_text())
|
|
rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(leaf_a),
|
|
'G': '', 'H': '', 'I': '', 'J': ''})
|
|
# 4-level (rare)
|
|
sub_ul2 = leaf_li.find('ul', recursive=False)
|
|
if sub_ul2:
|
|
for ll2 in sub_ul2.find_all('li', recursive=False):
|
|
la2 = ll2.find('a', recursive=False)
|
|
if la2:
|
|
G = clean_text(la2.get_text())
|
|
rows.append({'D': D, 'E': E, 'F': F, 'G': G,
|
|
'href': extract_href(la2),
|
|
'H': '', 'I': '', 'J': ''})
|
|
return rows
|
|
|
|
|
|
def parse_dl_dt_dd(soup, base, use_onclick=False):
|
|
"""홍성군·예산군·천안시·금산군 dl 형: div.sitemap (옵션 .type2) > dl > dt + dd > b > a + ul > li > a (+ ul > li > a).
|
|
|
|
use_onclick=True 인 경우 onclick의 encodeURI/location.href에서 URL을 추출.
|
|
"""
|
|
rows = []
|
|
sm = soup.select_one('div.sitemap[class*=type2]') or soup.select_one('div.sitemap')
|
|
if not sm:
|
|
return rows
|
|
href_fn = extract_href_with_onclick if use_onclick else extract_href
|
|
|
|
def walk(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 = clean_text(a.get_text())
|
|
href = href_fn(a)
|
|
path = base_path[:depth] + [(text, href)]
|
|
nested = li.find('ul', recursive=False)
|
|
if nested:
|
|
out.append({'path': list(path), 'href': href})
|
|
walk(nested, depth + 1, path, out)
|
|
else:
|
|
out.append({'path': list(path), 'href': href})
|
|
|
|
for dl in sm.find_all('dl', recursive=False):
|
|
dt = dl.find('dt')
|
|
dt_a = dt.find('a') if dt else None
|
|
D = clean_text(dt_a.get_text() if dt_a else (dt.get_text() 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
|
|
E = clean_text(b_a.get_text()) if b_a else ''
|
|
E_href = href_fn(b_a) if b_a else ''
|
|
nested = dd.find('ul', recursive=False)
|
|
if nested:
|
|
tmp = []
|
|
walk(nested, 0, [], tmp)
|
|
for item in tmp:
|
|
p = item['path']
|
|
row = {'D': D, 'E': E, '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
|
|
rows.append(row)
|
|
else:
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
return rows
|
|
|
|
|
|
def parse_seosan_top_menu(soup, base):
|
|
"""서산시: ul.top_menu > li.depth1 > a.depth1_ti (D) + div...div.depth2_wrap > ul.depth2 > li > a (E) + ul.depth3 > li > a (F)."""
|
|
rows = []
|
|
tm = soup.select_one('ul.top_menu')
|
|
if not tm:
|
|
return rows
|
|
for top_li in tm.find_all('li', class_='depth1', recursive=False):
|
|
a1 = top_li.find('a', class_='depth1_ti')
|
|
D = clean_text(a1.get_text()) if a1 else ''
|
|
depth2 = top_li.select_one('ul.depth2')
|
|
if not depth2:
|
|
continue
|
|
for d2_li in depth2.find_all('li', recursive=False):
|
|
d2_a = d2_li.find('a', recursive=False)
|
|
if not d2_a:
|
|
continue
|
|
E = clean_text(d2_a.get_text())
|
|
E_href = extract_href(d2_a)
|
|
d3 = d2_li.find('ul', class_='depth3', recursive=False)
|
|
if not d3:
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
for d3_li in d3.find_all('li', recursive=False):
|
|
d3_a = d3_li.find('a', recursive=False)
|
|
if not d3_a:
|
|
continue
|
|
F = clean_text(d3_a.get_text())
|
|
rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(d3_a),
|
|
'G': '', 'H': '', 'I': '', 'J': ''})
|
|
return rows
|
|
|
|
|
|
def parse_yesan_depth(soup, base):
|
|
"""예산군: #gnb > ul.depth1_ul > li > a.th_1st + div.item > ul.depth2_ul > li > a + ul.depth3_ul > li > a."""
|
|
rows = []
|
|
container = soup.select_one('#gnb ul.depth1_ul') or soup.select_one('ul.depth1_ul')
|
|
if not container:
|
|
return rows
|
|
for top_li in container.find_all('li', recursive=False):
|
|
a1 = top_li.find('a', class_='th_1st', recursive=False)
|
|
D = clean_text(a1.get_text()) if a1 else ''
|
|
item = top_li.find('div', class_='item')
|
|
if not item:
|
|
continue
|
|
d2_ul = item.find('ul', class_='depth2_ul')
|
|
if not d2_ul:
|
|
continue
|
|
for d2_li in d2_ul.find_all('li', recursive=False):
|
|
d2_a = d2_li.find('a', recursive=False)
|
|
if not d2_a:
|
|
continue
|
|
E = clean_text(d2_a.get_text())
|
|
E_href = extract_href(d2_a)
|
|
d3_ul = d2_li.find('ul', class_='depth3_ul', recursive=False)
|
|
if not d3_ul:
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
for d3_li in d3_ul.find_all('li', recursive=False):
|
|
d3_a = d3_li.find('a', recursive=False)
|
|
if not d3_a:
|
|
continue
|
|
F = clean_text(d3_a.get_text())
|
|
rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(d3_a),
|
|
'G': '', 'H': '', 'I': '', 'J': ''})
|
|
return rows
|
|
|
|
|
|
def parse_cheonan_depth(soup, base):
|
|
"""천안시: ul.depth1-ul > li > a.depth1-btn + div.depth1-content > div.layout > ul.depth2-ul > li > a.depth2-btn + div.depth2-content > ul.depth3-ul > li > a.depth3-btn."""
|
|
rows = []
|
|
container = soup.select_one('ul.depth1-ul')
|
|
if not container:
|
|
return rows
|
|
for top_li in container.find_all('li', recursive=False):
|
|
a1 = top_li.find('a', class_='depth1-btn', recursive=False)
|
|
D = clean_text(a1.get_text()) if a1 else ''
|
|
D_href = extract_href(a1) if a1 else ''
|
|
d2_ul = top_li.select_one('ul.depth2-ul')
|
|
if not d2_ul:
|
|
rows.append({'D': D, 'E': '', 'href': D_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
for d2_li in d2_ul.find_all('li', recursive=False):
|
|
d2_a = d2_li.find('a', class_='depth2-btn', recursive=False)
|
|
if not d2_a:
|
|
continue
|
|
E = clean_text(d2_a.get_text())
|
|
E_href = extract_href(d2_a)
|
|
d3_ul = d2_li.select_one('ul.depth3-ul')
|
|
if not d3_ul:
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
for d3_li in d3_ul.find_all('li', recursive=False):
|
|
d3_a = d3_li.find('a', class_='depth3-btn', recursive=False)
|
|
if not d3_a:
|
|
continue
|
|
F = clean_text(d3_a.get_text())
|
|
rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(d3_a),
|
|
'G': '', 'H': '', 'I': '', 'J': ''})
|
|
return rows
|
|
|
|
|
|
def parse_cheongyang_depth1(soup, base):
|
|
"""청양군: ul.depth1_ul > li > a.th_1st + div.item > ul.depth2_ul > li > a + ul.depth3_ul > li > a (예산군 형)."""
|
|
# 청양군은 예산군과 동일한 e-Gov GNB 구조 — yesan 파서 재사용
|
|
return parse_yesan_depth(soup, base)
|
|
|
|
|
|
def parse_asan_gnb(soup, base):
|
|
"""아산시: div#mGnb-anchor{n}.gnb-sub-list > ul > li > a.gnb-sub-trigger + ul.sub-ul > li > a.subm."""
|
|
rows = []
|
|
# 대분류 이름 — mobile-nav의 gnb-main-trigger 텍스트 + href(#mGnb-anchorN) 매핑
|
|
nav_main = soup.select_one('nav#mobile-nav')
|
|
main_categories = [] # [(D, anchor_id)]
|
|
if nav_main:
|
|
for trig in nav_main.find_all(['a', 'button'], class_='gnb-main-trigger'):
|
|
text = clean_text(trig.get_text())
|
|
target = trig.get('href') or trig.get('data-target') or ''
|
|
if target.startswith('#mGnb-anchor'):
|
|
main_categories.append((text, target.lstrip('#')))
|
|
# Fallback: sections without name mapping
|
|
if not main_categories:
|
|
for sec in soup.select('div[id^=mGnb-anchor]'):
|
|
main_categories.append((sec.get('id'), sec.get('id')))
|
|
|
|
for D, anchor_id in main_categories:
|
|
section = soup.find('div', id=anchor_id)
|
|
if not section:
|
|
continue
|
|
# section > ul > li > a.gnb-sub-trigger + ul.sub-ul > li > a.subm
|
|
for ul in section.find_all('ul', recursive=False):
|
|
for li in ul.find_all('li', recursive=False):
|
|
a_E = li.find('a', class_='gnb-sub-trigger', recursive=False) or li.find('a', recursive=False)
|
|
if not a_E:
|
|
continue
|
|
E = clean_text(a_E.get_text())
|
|
E_href = extract_href(a_E)
|
|
sub_ul = li.find('ul', class_='sub-ul', recursive=False) or li.find('ul', recursive=False)
|
|
if not sub_ul:
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
for sub_li in sub_ul.find_all('li', recursive=False):
|
|
sub_a = sub_li.find('a', recursive=False)
|
|
if not sub_a:
|
|
continue
|
|
F = clean_text(sub_a.get_text())
|
|
rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(sub_a),
|
|
'G': '', 'H': '', 'I': '', 'J': ''})
|
|
return rows
|
|
|
|
|
|
def parse_buyeo_topmenu(soup, base):
|
|
"""부여군: ul#tm > li.th1 > a.th1_lnk (D) + div.summry > ul.th2 > li > a.th2_lnk (E) + ul.th3 > li > a (F)."""
|
|
rows = []
|
|
tm = soup.select_one('ul#tm')
|
|
if not tm:
|
|
return rows
|
|
for top_li in tm.find_all('li', class_=re.compile(r'th1?'), recursive=False):
|
|
a1 = top_li.find('a', class_='th1_lnk', recursive=False)
|
|
if not a1:
|
|
a1 = top_li.find('a', recursive=False)
|
|
if not a1:
|
|
continue
|
|
D = clean_text(a1.get_text())
|
|
D_href = extract_href(a1)
|
|
# Find ul.th2 inside div.summry
|
|
summry = top_li.find('div', class_=re.compile(r'summry'), recursive=False)
|
|
th2 = (summry.find('ul', class_='th2') if summry else None) or top_li.find('ul', class_='th2')
|
|
if not th2:
|
|
rows.append({'D': D, 'E': '', 'href': D_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
for li2 in th2.find_all('li', recursive=False):
|
|
a2 = li2.find('a', class_='th2_lnk', recursive=False) or li2.find('a', recursive=False)
|
|
if not a2:
|
|
continue
|
|
E = clean_text(a2.get_text())
|
|
E_href = extract_href(a2)
|
|
th3 = li2.find('ul', class_='th3', recursive=False)
|
|
if not th3:
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
continue
|
|
rows.append({'D': D, 'E': E, 'href': E_href,
|
|
'F': '', 'G': '', 'H': '', 'I': '', 'J': ''})
|
|
for li3 in th3.find_all('li', recursive=False):
|
|
a3 = li3.find('a', recursive=False)
|
|
if not a3:
|
|
continue
|
|
F = clean_text(a3.get_text())
|
|
rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(a3),
|
|
'G': '', 'H': '', 'I': '', 'J': ''})
|
|
return rows
|
|
|
|
|
|
# ================================================================
|
|
# 사이트 설정
|
|
# ================================================================
|
|
|
|
SITES = [
|
|
{
|
|
'idx': 5, 'name': '당진시', 'base': 'https://www.dangjin.go.kr',
|
|
'sitemap': 'https://www.dangjin.go.kr/kor/sitemap_11.do',
|
|
'sheet': '05_당진시', 'parser': parse_amThum,
|
|
'domain': 'dangjin.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\5.당진시',
|
|
},
|
|
{
|
|
'idx': 6, 'name': '보령시', 'base': 'https://www.brcn.go.kr',
|
|
'sitemap': 'https://www.brcn.go.kr/kor/sitemap_11.do',
|
|
'sheet': '06_보령시', 'parser': parse_ul_sitemap_h4,
|
|
'domain': 'brcn.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\6.보령시',
|
|
},
|
|
{
|
|
'idx': 7, 'name': '부여군', 'base': 'https://www.buyeo.go.kr',
|
|
'sitemap': 'https://www.buyeo.go.kr/html/kr/',
|
|
'sheet': '07_부여군', 'parser': parse_buyeo_topmenu,
|
|
'domain': 'buyeo.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\7.부여군',
|
|
},
|
|
{
|
|
'idx': 8, 'name': '서산시', 'base': 'https://www.seosan.go.kr',
|
|
'sitemap': 'https://www.seosan.go.kr/www/index.do',
|
|
'sheet': '08_서산시', 'parser': parse_seosan_top_menu,
|
|
'domain': 'seosan.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\8.서산시',
|
|
},
|
|
{
|
|
'idx': 9, 'name': '서천군', 'base': 'https://www.seocheon.go.kr',
|
|
'sitemap': 'https://www.seocheon.go.kr/kor/sitemap_11.do',
|
|
'sheet': '09_서천군', 'parser': parse_ul_sitemap_h4,
|
|
'domain': 'seocheon.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\9.서천군',
|
|
},
|
|
{
|
|
'idx': 10, 'name': '아산시', 'base': 'https://www.asan.go.kr',
|
|
'sitemap': 'https://www.asan.go.kr/main/',
|
|
'sheet': '10_아산시', 'parser': parse_asan_gnb,
|
|
'domain': 'asan.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\10.아산시',
|
|
},
|
|
{
|
|
'idx': 11, 'name': '예산군', 'base': 'https://www.yesan.go.kr',
|
|
'sitemap': 'https://www.yesan.go.kr/kor/sitemap.do',
|
|
'sheet': '11_예산군', 'parser': parse_yesan_depth,
|
|
'domain': 'yesan.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\11.예산군',
|
|
},
|
|
{
|
|
'idx': 12, 'name': '천안시', 'base': 'https://www.cheonan.go.kr',
|
|
'sitemap': 'https://www.cheonan.go.kr/kor/sitemap.do',
|
|
'sheet': '12_천안시', 'parser': parse_cheonan_depth,
|
|
'domain': 'cheonan.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\12.천안시',
|
|
},
|
|
{
|
|
'idx': 13, 'name': '청양군', 'base': 'https://www.cheongyang.go.kr',
|
|
'sitemap': 'https://www.cheongyang.go.kr/kor/sitemap_11.do',
|
|
'sheet': '13_청양군', 'parser': parse_cheongyang_depth1,
|
|
'domain': 'cheongyang.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\13.청양군',
|
|
},
|
|
{
|
|
'idx': 14, 'name': '태안군', 'base': 'https://www.taean.go.kr',
|
|
'sitemap': 'https://www.taean.go.kr/kor/sitemap_11.do',
|
|
'sheet': '14_태안군', 'parser': parse_amThum,
|
|
'domain': 'taean.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\14.태안군',
|
|
},
|
|
{
|
|
'idx': 15, 'name': '홍성군', 'base': 'https://www.hongseong.go.kr',
|
|
'sitemap': 'https://www.hongseong.go.kr/kor/sitemap.do',
|
|
'sheet': '15_홍성군',
|
|
'parser': lambda soup, base: parse_dl_dt_dd(soup, base, use_onclick=True),
|
|
'domain': 'hongseong.go.kr',
|
|
'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\15.홍성군',
|
|
},
|
|
]
|
|
|
|
|
|
# ================================================================
|
|
# 엑셀 생성 (공통)
|
|
# ================================================================
|
|
|
|
|
|
def write_excel(site, raw_rows):
|
|
name = site['name']
|
|
base = site['base']
|
|
domain = site['domain']
|
|
output = f"{site['folder']}\\{os.path.basename(os.path.dirname(site['folder']))}_{name}.xlsx"
|
|
|
|
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 domain not in url
|
|
|
|
# 부모-자식 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' [{name}] 원본 {len(raw_rows)} → 중복 {removed} → 최종 {len(final_rows)}')
|
|
if not final_rows:
|
|
print(f' [{name}] !! 행 0개 — 파서 점검 필요. 엑셀 생성 스킵.')
|
|
return False
|
|
|
|
shutil.copy(TEMPLATE, output)
|
|
wb = openpyxl.load_workbook(output)
|
|
ws = wb.active
|
|
ws.title = site['sheet']
|
|
|
|
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 = name
|
|
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
|
|
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
|
|
|
|
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
|
|
|
|
wb.save(output)
|
|
ext_n = sum(1 for r in range(START, END + 1) if ws.cell(r, 19).value == '외부링크')
|
|
print(f' [{name}] 병합 D:{n_d} E:{n_e} F:{n_f} | 외부링크 {ext_n} | K링크 {link_n} → {output}')
|
|
return True
|
|
|
|
|
|
def main():
|
|
targets = sys.argv[1:] if len(sys.argv) > 1 else [s['name'] for s in SITES]
|
|
for site in SITES:
|
|
if site['name'] not in targets:
|
|
continue
|
|
print(f"\n{'='*60}\n[{site['idx']}.{site['name']}] {site['sitemap']}\n{'='*60}")
|
|
try:
|
|
html = fetch_html(site['sitemap'])
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|
raw_rows = site['parser'](soup, site['base'])
|
|
write_excel(site, raw_rows)
|
|
except Exception as e:
|
|
print(f' [{site["name"]}] !! 실패: {e}')
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|