"""외교부·성평등가족부 메인 페이지에서 GNB+하위 메뉴 트리 추출. 목적: breadcrumb 없는 사이트의 실제 메뉴 구조를 파악하기 위해 GNB(상단 전체메뉴 영역)의 다단계 ul/li 트리를 가져옴. 방법: 1. 메인 페이지 HTML에서 nav.gnb, #gnb, .navigation 같은 컨테이너 찾기 2. 또는 "전체메뉴/사이트맵" 버튼을 누르면 펼쳐지는 영역 3. URL → 메뉴 path 매핑 사전 만들기 """ import sys, io, json, re from urllib.parse import urljoin, urlparse sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') import requests, urllib3 urllib3.disable_warnings() HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.9', 'Accept-Language': 'ko-KR,ko;q=0.9', } s = requests.Session(); s.headers.update(HEADERS) from bs4 import BeautifulSoup # 사이트별 메인 URL과 GNB 컨테이너 셀렉터 후보 SITES = { '외교부': { 'main': 'https://www.mofa.go.kr/www/index.do', 'base': 'https://www.mofa.go.kr', 'gnb_sel': ['#gnb', '.gnb', 'nav.gnb', '.gnb_wrap', '.top-menu', '.allmenu', '.sitemap', '#allMenu', '#sitemap', '.global_nav', '#mainNav', '.main_menu', 'header nav', '.nav-list'], }, '성평등가족부': { 'main': 'https://www.mogef.go.kr/', 'base': 'https://www.mogef.go.kr', 'gnb_sel': ['#gnb', '.gnb', '.gnbWrap', '.allMenu', '#allmenu', '.sitemap_wrap', 'nav.gnb', '.lnb', '.main_menu', '.menu_wrap', '#menu', '.mainMenu', '.snb', 'header nav', '#header nav'], }, } # 메뉴 트리 추출 — 다단계 ul/li 구조 재귀 파싱 def parse_menu(ul, depth=1, path=None, out=None): if out is None: out = [] if path is None: path = [] for li in ul.find_all('li', recursive=False): # a 태그의 텍스트와 href a = li.find('a', recursive=False) if not a: # children 만 있을 수도 sub_ul = li.find('ul', recursive=False) if sub_ul: parse_menu(sub_ul, depth+1, path, out) continue text = a.get_text(' ', strip=True) href = a.get('href', '').strip() new_path = path + [text] if text else path out.append({'depth': depth, 'path': new_path, 'text': text, 'href': href}) # 하위 ul sub_ul = li.find('ul', recursive=False) if sub_ul: parse_menu(sub_ul, depth+1, new_path, out) return out # 메인 페이지에서 GNB 추출 (또는 전체 사이트맵 페이지) for name, conf in SITES.items(): print(f'\n========== {name} ==========') try: r = s.get(conf['main'], verify=False, timeout=20) print(f'main HTTP {r.status_code} / len={len(r.text)}') soup = BeautifulSoup(r.text, 'html.parser') found_ul = None used_sel = None for sel in conf['gnb_sel']: cont = soup.select_one(sel) if cont: # 컨테이너 안에서 다단계 ul 찾기 — 가장 li가 많은 ul uls = cont.find_all('ul', recursive=True) if uls: best = max(uls, key=lambda u: len(u.find_all('li', recursive=False))) if len(best.find_all('li', recursive=False)) >= 3: found_ul = best used_sel = sel break # 전체 페이지에서 가장 큰 nav ul 찾기 (백업) if not found_ul: # ul 중에서 깊이 2+ 이고 li 5+ 인 것 for ul in soup.find_all('ul'): lis = ul.find_all('li', recursive=False) if len(lis) >= 5: has_nested = any(li.find('ul') for li in lis) if has_nested: found_ul = ul used_sel = 'auto-detect' break if found_ul: print(f'GNB ul 발견 (셀렉터: {used_sel})') print(f' 최상위 li: {len(found_ul.find_all("li", recursive=False))}개') tree = parse_menu(found_ul) print(f' 전체 노드: {len(tree)}') # 깊이별 통계 from collections import Counter print(f' 깊이 분포: {dict(Counter(n["depth"] for n in tree))}') # 샘플 출력 (depth 1) for n in tree: if n['depth'] == 1: print(f' [D] {n["text"]}') # 절대 URL for n in tree: if n['href'] and not n['href'].startswith('http'): if n['href'].startswith('/'): n['absUrl'] = conf['base'] + n['href'] elif n['href'].startswith('javascript:') or n['href'].startswith('#'): n['absUrl'] = None else: n['absUrl'] = urljoin(conf['main'], n['href']) else: n['absUrl'] = n['href'] if n['href'] else None out = rf'D:\01.프로젝트\DB수집\2주차\검토_리포트\{name}_gnb.json' with open(out, 'w', encoding='utf-8') as f: json.dump(tree, f, ensure_ascii=False, indent=2) print(f' saved: {out}') else: print('GNB ul 못 찾음') except Exception as e: print(f'ERR: {e}')