공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
7.0 KiB
Python
182 lines
7.0 KiB
Python
"""전북·제주 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'<meta[^>]*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()
|