"""전북·제주 16개 시·군 사이트맵 URL 탐색 + 컨테이너 구조 덤프.""" import re import ssl import sys import warnings from urllib.parse import urljoin, urlparse import requests from requests.adapters import HTTPAdapter from urllib3.util.ssl_ import create_urllib3_context from bs4 import BeautifulSoup 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} class WeakSSLAdapter(HTTPAdapter): def init_poolmanager(self, *args, **kwargs): ctx = create_urllib3_context() ctx.set_ciphers('DEFAULT@SECLEVEL=0') ctx.options |= 0x4 ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE kwargs['ssl_context'] = ctx return super().init_poolmanager(*args, **kwargs) def make_session(weak=False): s = requests.Session() s.headers.update(H) if weak: s.mount('https://', WeakSSLAdapter()) return s def fetch(session, url, timeout=20): r = session.get(url, timeout=timeout, verify=False, allow_redirects=True) meta = re.search(rb']*charset=["\']?\s*([\w-]+)', r.content[:4096], re.I) if meta: r.encoding = meta.group(1).decode('ascii', errors='ignore') else: r.encoding = r.apparent_encoding return r SITES = [ ('고창군', 'https://www.gochang.go.kr/index.gochang?contentsSid=3136'), ('군산시', 'https://www.gunsan.go.kr/main'), ('김제시', 'https://www.gimje.go.kr/index.gimje'), ('남원시', 'https://www.namwon.go.kr/index.do?menuUid=ff8080818e3beff0018e40e8f63e02d2'), ('무주군', 'https://www.muju.go.kr/index.9is'), ('부안군', 'https://www.buan.go.kr/index.buan?contentsSid=1'), ('순창군', 'https://www.sunchang.go.kr/'), ('완주군', 'https://www.wanju.go.kr/index.9is'), ('익산시', 'https://www.iksan.go.kr/index.do?menuUid=ff8080819a39930e019a4de8c1ae0afd'), ('임실군', 'https://www.imsil.go.kr/index.imsil'), ('장수군', 'https://www.jangsu.go.kr/index.jangsu'), ('전주시', 'https://www.jeonju.go.kr/index.9is'), ('정읍시', 'https://www.jeongeup.go.kr/index.jeongeup'), ('진안군', 'https://www.jinan.go.kr/index.jinan?contentsSid=1379'), ('서귀포시', 'https://www.seogwipo.go.kr/index.htm'), ('제주시', 'https://www.jejusi.go.kr/index.ac'), ] def find_sitemap_links(soup, base): """페이지에서 사이트맵으로 보이는 링크 후보 수집.""" cands = [] for a in soup.find_all('a', href=True): txt = (a.get_text() or '').strip() href = a['href'] onclick = a.get('onclick', '') blob = f'{txt} {href} {onclick}'.lower() if '사이트맵' in txt or 'sitemap' in blob: url = href if url.startswith('#') or url.lower().startswith('javascript:'): # onclick에서 추출 시도 m = re.search(r"""['"]([^'"]*(?:sitemap|site_map)[^'"]*)['"]""", onclick, re.I) if m: url = m.group(1) else: continue cands.append((txt, urljoin(base, url))) # 중복 제거 seen = set(); out = [] for t, u in cands: if u not in seen: seen.add(u); out.append((t, u)) return out def dump_structure(soup): """사이트맵 페이지 주요 컨테이너 후보 출력.""" # 흔한 사이트맵 컨테이너 셀렉터 후보 sels = [ 'div.sitemap', 'div#sitemap', 'div.site_map', 'div#site_map', 'ul#menu_sitemap', 'div.depth.depth1', 'div.depth1', 'ul.depth1_ul', 'div.amThum', 'ul.sitemap', 'div.sitemap_wrap', 'div.contents_sitemap', 'div.sitemapWrap', 'div.site-map', 'div.allmenu', 'div#allmenu', 'div.gnb_all', 'div.total_menu', ] found = [] for sel in sels: els = soup.select(sel) if els: found.append((sel, len(els))) print(f' 매칭 셀렉터: {found}') # 사이트맵스러운 컨테이너 한 개 잡아서 자식 구조 덤프 target = None for sel, _ in found: target = soup.select_one(sel) if target: print(f' >>> {sel} 내부 구조:') break if not target: # body에서 class에 sitemap/menu/depth 포함 div 찾기 for div in soup.find_all(['div', 'ul'], class_=True): cls = ' '.join(div.get('class', [])) if re.search(r'sitemap|site_map|allmenu|depth1|total_menu', cls, re.I): target = div print(f' >>> <{div.name} class="{cls}"> 내부 구조:') break if not target: print(' !! 사이트맵 컨테이너 미발견') return # 자식 1~3레벨 태그/클래스 요약 def summarize(el, depth=0, maxdepth=4): if depth > maxdepth: return for child in el.find_all(recursive=False): cls = '.'.join(child.get('class', [])) idv = child.get('id', '') tag = child.name label = tag + (f'#{idv}' if idv else '') + (f'.{cls}' if cls else '') a = child.find('a', recursive=False) atxt = (a.get_text().strip()[:20] if a else '') print(' ' * (depth + 1) + f'{label}' + (f' a="{atxt}"' if atxt else '')) if depth < 2: summarize(child, depth + 1, maxdepth) summarize(target) def main(): targets = sys.argv[1:] for name, url in SITES: if targets and name not in targets: continue print(f'\n{"="*70}\n[{name}] {url}') for weak in (False, True): try: sess = make_session(weak=weak) r = fetch(sess, url) base = f'{urlparse(r.url).scheme}://{urlparse(r.url).netloc}' soup = BeautifulSoup(r.text, 'html.parser') print(f' status={r.status_code} final={r.url} weak_ssl={weak}') links = find_sitemap_links(soup, base) print(f' 사이트맵 링크 후보: {links[:6]}') # 가장 그럴듯한 후보 따라가기 if links: smurl = links[0][1] try: r2 = fetch(sess, smurl) soup2 = BeautifulSoup(r2.text, 'html.parser') print(f' 사이트맵 페이지: {r2.url} (status {r2.status_code})') dump_structure(soup2) except Exception as e: print(f' 사이트맵 페이지 fetch 실패: {e}') else: print(' >>> 인덱스 자체 구조 확인:') dump_structure(soup) break except Exception as e: if weak: print(f' !! 실패(weak 포함): {type(e).__name__}: {e}') else: print(f' (일반 SSL 실패 → weak 재시도): {type(e).__name__}') if __name__ == '__main__': main()