DB_JOB/_스크립트/_공공기관_phase1.py
hehihoho3 df16c98366 백업: DB수집 전체 스냅샷 (공공기관2 정리 전)
공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:15:40 +09:00

393 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""공공기관 Phase 1: 사이트맵/메가메뉴 → 메뉴트리 D~K (Playwright 렌더 + 범용 중첩리스트 추출).
출력: D:\\01.프로젝트\\DB수집\\공공기관\\{기관}.xlsx (평면배치)
시트명: {번호}_{기관}
사용: python _공공기관_phase1.py [기관명 ...] (인자 없으면 OVERRIDES 정의된 전체)
"""
import sys, io, os, re, shutil, json, warnings
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from copy import copy
from urllib.parse import urljoin, urlparse
import openpyxl
from bs4 import BeautifulSoup
from openpyxl.styles import Alignment, Font
warnings.filterwarnings('ignore')
TEMPLATE = r'D:\01.프로젝트\DB수집\자료_취합_예시.xlsx'
OUTDIR = r'D:\01.프로젝트\DB수집\공공기관'
PROBE = r'D:\01.프로젝트\DB수집\_스크립트\_공공기관_probe.json'
UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
# 기관별 오버라이드: sitemap URL + 컨테이너 셀렉터(선택) + 렌더 대기(wait). probe json을 기본값으로.
OVERRIDES = {
'국립생태원': {'sitemap': 'https://www.nie.re.kr/nie/main/main.do', 'wait': 5000},
'국토안전관리원': {'use_home': True, 'sel': '.all-menu', 'wait': 4500},
'국립중앙의료원': {'use_home': True, 'sel': '#siteMap', 'wait': 4500},
'건설근로자공제회': {'use_home': True, 'wait': 4500},
'국민연금공단': {'sitemap': 'https://www.nps.or.kr/main.do', 'sel': '.allmenu-container', 'wait': 4000},
'국민건강보험공단': {'sitemap': 'https://www.nhis.or.kr/nhis/index.do', 'sel': 'nav.head-gnb', 'wait': 4000},
}
JS_PATS = [
# goMenuPage('MENUID','/realpath',...) 형: 2번째 인자가 실제 URL
re.compile(r"""go\w*(?:Menu|Page|Move)\w*\s*\(\s*['"][^'"]*['"]\s*,\s*['"]([^'"]+)['"]""", re.I),
re.compile(r"""go(?:Menu|SubMenu|Page|Link|Url|View|Move)\s*\(\s*['"]([^'"]+)['"]""", re.I),
re.compile(r"""(?:location\.href|window\.location(?:\.href)?)\s*=\s*(?:encodeURI\()?\s*['"]([^'"]+)['"]"""),
re.compile(r"""window\.open\s*\(\s*['"]([^'"]+)['"]"""),
re.compile(r"""(?:fn_?\w*|aLink|movePage|menuMove)\s*\(\s*['"]([^'"]+)['"]""", re.I),
]
def js_href(a):
s = (a.get('href', '') or '') + ' ' + (a.get('onclick', '') or '')
for p in JS_PATS:
mm = p.search(s)
if mm:
u = mm.group(1).strip()
if u.startswith(('/', 'http', './', '?', '../')):
return u
return ''
def clean(s):
return re.sub(r'\s+', ' ', (s or '')).strip().replace('\xa0', '')
def render(url, wait=2500):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page(user_agent=UA, viewport={'width': 1440, 'height': 2400})
try:
pg.goto(url, timeout=30000, wait_until='domcontentloaded')
except Exception:
pass
pg.wait_for_timeout(wait)
html = pg.content()
final = pg.url
b.close()
return final, html
SITEMAP_HINTS = ('sitemap', 'site_map', 'sitemapwrap', 'site-map', 'allmenu', 'all_menu', 'all-menu',
'menu_all', 'menuall', 'totalmenu', 'total_menu', 'totmenu', 'full_menu', 'fullmenu',
'gnb_all', 'gnball')
def pick_container(soup, prefer=None):
if prefer:
el = soup.select_one(prefer)
if el and len(el.find_all('a')) >= 8:
return el
cands = []
for d in soup.find_all(['div', 'section', 'main', 'nav', 'ul']):
cls = ' '.join(d.get('class', [])).lower() + ' ' + (d.get('id', '') or '').lower()
clsn = cls.replace(' ', '').replace('-', '').replace('_', '')
if any(x in cls for x in ['footer', 'aside']):
continue
ac = len(d.find_all('a'))
ul = len(d.find_all('ul'))
if ac < 12:
continue
score = ac + ul * 2
# 사이트맵/전체메뉴 컨테이너 강력 우대
if any(h in clsn for h in SITEMAP_HINTS):
score += 1000
# 전역 헤더/상단 gnb는 (사이트맵 페이지에선) 감점
if 'header' in cls or ('gnb' in clsn and not any(h in clsn for h in SITEMAP_HINTS)):
score -= 200
cands.append((score, ac, d))
if not cands:
return None
cands.sort(key=lambda x: x[0], reverse=True)
return cands[0][2]
NOISE_LABELS = {'메뉴없음', '메뉴 없음', '', 'home', 'home으로', '홈으로', 'eng', 'english', '로그인',
'login', '회원가입', '검색', 'search', '바로가기', '본문바로가기', '닫기', 'close', '전체메뉴',
'사이트맵', 'sitemap'}
def node_depth(el, container):
"""container 내부에서 el 위의 ul/ol/dl 조상 개수 = 중첩 깊이."""
d = 0
p = el.parent
while p is not None and p is not container:
if getattr(p, 'name', None) in ('ul', 'ol', 'dl'):
d += 1
p = p.parent
return d
def parse_table_sitemap(table):
"""테이블형 사이트맵: tr > th(대분류, 빈칸=이어받기) + th(중분류 a) + td(소분류 a들)."""
rows = []
curD = ''
for tr in table.find_all('tr'):
ths = tr.find_all('th', recursive=False)
if ths:
d_txt = clean(ths[0].get_text())
# 첫 th에 a가 없고 span/텍스트면 대분류 라벨(빈칸이면 이어받기)
if d_txt and not (len(ths) == 1 and ths[0].find('a')):
# 첫 th가 곧 중분류 a 단독인 경우는 제외 위해 a 유무 확인
if not ths[0].find('a') or len(ths) >= 2:
if not ths[0].find('a'):
curD = d_txt
E = ''; Eh = ''
if len(ths) >= 2:
ea = ths[1].find('a')
if ea:
E = clean(ea.get_text()); Eh = js_href(ea) or (ea.get('href') or '')
elif len(ths) == 1 and ths[0].find('a'):
ea = ths[0].find('a'); E = clean(ea.get_text()); Eh = js_href(ea) or (ea.get('href') or '')
td = tr.find('td')
leaves = td.find_all('a') if td else []
if leaves:
for la in leaves:
lab = clean(la.get_text())
if not lab:
continue
rows.append({'path': [curD, E, lab], 'href': js_href(la) or (la.get('href') or '')})
elif E:
rows.append({'path': [curD, E], 'href': Eh})
return [r for r in rows if r['path'] and any(r['path'])]
def extract_rows(container):
"""앵커 중심 깊이추출: 각 a/heading의 ul조상 수로 컬럼 결정(정규화). 범용. 테이블형 자동전환."""
tbls = container.find_all('table')
if tbls and sum(len(t.find_all('th')) for t in tbls) >= 4 and len(container.find_all('ul')) <= 1:
rows = []
for t in tbls:
rows += parse_table_sitemap(t)
if rows:
return rows
nodes = [] # [depth, label, href]
for el in container.find_all(['a', 'button', 'h2', 'h3', 'h4', 'h5', 'h6', 'dt']):
name = el.name
if name == 'a':
href = (el.get('href') or '').strip()
if href.startswith('#') or href.lower().startswith('javascript:') or not href:
href = js_href(el)
label = clean(el.get_text())
elif name == 'button':
cls = ' '.join(el.get('class', [])).lower()
if not (el.get('aria-expanded') is not None or el.get('aria-haspopup')
or any(x in cls for x in ('depth', 'trigger', 'gnb', '1d', '2d', '3d', 'menu'))):
continue
href = ''
label = clean(el.get('data-dir') or el.get('title') or el.get_text())
else:
if el.find('a'): # heading 안의 a는 따로 잡힘 → 중복방지
continue
href = ''
label = clean(el.get_text())
if not label or label.lower() in NOISE_LABELS or len(label) > 60:
continue
nodes.append([node_depth(el, container), label, href])
# 연속 중복 제거
dedup = []
for n in nodes:
if dedup and dedup[-1] == n:
continue
dedup.append(n)
nodes = dedup
if not nodes:
return []
mind = min(n[0] for n in nodes)
cols = [min(n[0] - mind, 6) for n in nodes]
out = []
path = [''] * 7
for i, (depth, label, href) in enumerate(nodes):
c = cols[i]
path[c] = label
for k in range(c + 1, 7):
path[k] = ''
is_leaf = (i == len(nodes) - 1) or (cols[i + 1] <= c)
if href or is_leaf:
out.append({'path': path[:c + 1], 'href': href})
return out
def rows_to_dicts(raw):
out = []
for it in raw:
p = it['path']
href = it.get('href', '')
if href.startswith('#') or href.lower().startswith('javascript:'):
href = ''
row = {'D': '', 'E': '', 'F': '', 'G': '', 'H': '', 'I': '', 'J': '', 'href': href}
for i, lab in enumerate(p[:7]):
row['DEFGHIJ'[i]] = lab
out.append(row)
return out
def write_excel(name, num, base, raw_rows):
domain = urlparse(base).netloc
output = os.path.join(OUTDIR, f'{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 중복 제거 (전 깊이) — 부모행 D~lc 동일 + lc+1 채워짐 + K동일
def colval(r, c):
return r.get(c, '') or ''
final = []
i = 0
removed = 0
cols = 'DEFGHIJ'
while i < len(raw_rows):
row = raw_rows[i]
# 마지막 채워진 컬럼 찾기
lc = -1
for ci, c in enumerate(cols):
if colval(row, c) != '':
lc = ci
dup = False
if i + 1 < len(raw_rows) and lc >= 0 and lc < 6:
nxt = raw_rows[i + 1]
same = all(colval(row, cols[k]) == colval(nxt, cols[k]) for k in range(lc + 1))
if (same and colval(nxt, cols[lc + 1]) != '' and colval(row, cols[lc + 1]) == ''
and row.get('href', '') == nxt.get('href', '')):
dup = True
if dup:
removed += 1
i += 1
continue
final.append(row)
i += 1
if not final:
print(f' [{name}] 행 0개 — 스킵')
return 0
shutil.copy(TEMPLATE, output)
wb = openpyxl.load_workbook(output)
ws = wb.active
ws.title = f'{num:02d}_{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, 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
for ci, c in enumerate('DEFGHIJ'):
ws.cell(idx, 4 + ci).value = item.get(c, '')
ws.cell(idx, 11).value = url
if is_external(url):
ws.cell(idx, 19).value = '외부링크'
END = START + len(final) - 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)
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}] 원본{len(raw_rows)}→중복{removed}{len(final)}행 | 병합D{n_d}E{n_e}F{n_f} | 외부{ext_n} K링크{link_n}{output}')
return len(final)
def load_probe():
with open(PROBE, encoding='utf-8') as f:
return {r['name']: r for r in json.load(f)}
def main():
probe = load_probe()
targets = sys.argv[1:] if len(sys.argv) > 1 else list(probe.keys())
for name in targets:
p = probe.get(name)
if not p:
print(f'[{name}] probe 정보 없음 — 스킵'); continue
ov = OVERRIDES.get(name, {})
num = int(p['num'])
base = p['base']
if ov.get('use_home'):
sm = p['home']
else:
sm = ov.get('sitemap') or p.get('sitemap') or p['home']
wait = ov.get('wait', 3000)
print(f"\n=== {num}.{name} === render {sm}")
try:
final, html = render(sm, wait=wait)
soup = BeautifulSoup(html, 'html.parser')
cont = pick_container(soup, ov.get('sel'))
if not cont:
print(f' [{name}] 컨테이너 못찾음 (a없음)'); continue
raw = extract_rows(cont)
dicts = rows_to_dicts(raw)
write_excel(name, num, base, dicts)
except Exception as e:
import traceback
print(f' [{name}] 실패: {e}')
traceback.print_exc()
if __name__ == '__main__':
main()