"""충청북도 11개 시·군 Phase 1 일괄 처리. 매뉴얼: D:\\01.프로젝트\\DB수집\\사이트맵_수집_매뉴얼.md 출력: 각 폴더의 {기관명}.xlsx """ import os import re import shutil import ssl import sys import warnings from copy import copy from urllib.parse import urljoin import openpyxl import requests from requests.adapters import HTTPAdapter from urllib3.util.ssl_ import create_urllib3_context 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' class WeakSSLAdapter(HTTPAdapter): """레거시 SSL 핸드셰이크 허용 (영동군 등).""" def init_poolmanager(self, *args, **kwargs): ctx = create_urllib3_context() ctx.set_ciphers('DEFAULT@SECLEVEL=0') ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE kwargs['ssl_context'] = ctx return super().init_poolmanager(*args, **kwargs) def make_session(weak_ssl=False): s = requests.Session() s.headers.update(H) if weak_ssl: s.mount('https://', WeakSSLAdapter()) return s def fetch_html(url, session=None, timeout=20, force_encoding=None): s = session or requests.Session() if not session: s.headers.update(H) r = s.get(url, timeout=timeout, verify=False, allow_redirects=True) if force_encoding: r.encoding = force_encoding else: # 메타 태그에서 charset 시도 → 없으면 apparent_encoding meta_charset = re.search(rb']*charset=["\']?\s*([\w-]+)', r.content[:4096], re.I) if meta_charset: r.encoding = meta_charset.group(1).decode('ascii', errors='ignore') else: 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:'): 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): 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 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 '' # ================================================================ # 파서들 # ================================================================ def parse_depth1_chungbuk(soup, base): """충북 e-Gov depth1 형: div.depth1 > ul.depth1_list > li.depth1_item > a.depth1_text + div.depth2 > [div.depth2_content|depth2_wrap] > ul.depth2_list.""" rows = [] container = soup.select_one('div.depth.depth1, div.depth1') if not container: return rows top_ul = container.find('ul', class_=re.compile(r'depth1?_list'), recursive=False) if not top_ul: return rows def walk_depth(ul, level, base_path, out): for li in ul.find_all('li', recursive=False): a = li.find('a', class_=re.compile(rf'depth{level}_text'), recursive=False) or li.find('a', recursive=False) if not a: continue text = clean_text(a.get_text()) href = extract_href(a) path = base_path + [(text, href)] # Find next depth container — 다양한 wrapper 클래스 지원: _content / _wrap / 없음 next_div = li.find('div', class_=re.compile(rf'depth\s+depth{level+1}\b'), recursive=False) or \ li.find('div', class_=re.compile(rf'\bdepth{level+1}\b'), recursive=False) if next_div: # wrapper 가 있을 수도, 없을 수도 next_ul = next_div.find('ul', class_=re.compile(rf'depth{level+1}_list'), recursive=True) if next_ul: # 같은 깊이의 첫번째 ul.depth_list (자손 검색이지만 보통 1번 wrapper 안) out.append({'path': list(path), 'href': href}) walk_depth(next_ul, level + 1, path, out) continue out.append({'path': list(path), 'href': href}) tmp = [] walk_depth(top_ul, 1, [], tmp) cols = 'DEFGHIJ' for item in tmp: p = item['path'] row = {c: '' for c in 'DEFGHIJ'} row['href'] = item['href'] for i, (t, _) in enumerate(p): col = cols[i] if i < len(cols) else 'J' row[col] = t rows.append(row) return rows def parse_danyang_ld(soup, base): """단양군: ul#menu_sitemap.ld1 > li.cd1 > a.l1 + div.lb1 > ul.ld2 > li.cd2 > a.l2 + div.lb2 > ul.ld3 > ... 'menutype_empty' (메인 등) 은 스킵. """ rows = [] container = soup.select_one('ul#menu_sitemap') if not container: return rows def walk(ul, level, base_path, out): for li in ul.find_all('li', recursive=False): a = li.find('a', class_=re.compile(rf'\bl{level}\b'), recursive=False) or li.find('a', recursive=False) if not a: continue a_cls = ' '.join(a.get('class', [])) if 'menutype_empty' in a_cls: continue # 메인 같은 빈 항목 text = clean_text(a.get_text()) href = extract_href(a) path = base_path + [(text, href)] next_div = li.find('div', class_=re.compile(rf'\blb{level}\b'), recursive=False) if next_div: next_ul = next_div.find('ul', class_=re.compile(rf'\bld{level+1}\b'), recursive=False) if next_ul: out.append({'path': list(path), 'href': href}) walk(next_ul, level + 1, path, out) continue out.append({'path': list(path), 'href': href}) tmp = [] walk(container, 1, [], tmp) cols = 'DEFGHIJ' for item in tmp: p = item['path'] row = {c: '' for c in cols} row['href'] = item['href'] for i, (t, _) in enumerate(p): col = cols[i] if i < len(cols) else 'J' row[col] = t rows.append(row) return rows def parse_dl_dt_dd(soup, base, use_onclick=False): """계룡시·홍성군·영동군형: div.sitemap > dl > dt + dd > b > a + ul > li > a (+ ul > li > a).""" rows = [] sm = soup.select_one('div.sitemap[class*=type2]') or soup.select_one('div.sitemap[class*=type1]') 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_yesan_depth(soup, base): """예산·청양·증평형: ul.depth1_ul > li > a (D, .th_1st/.th1_lnk) + [div.item >]? ul.depth2_ul > li > a (E) + ul.depth3_ul > li > a (F). div.item wrapper 있을수도 없을수도 — 둘 다 지원. a 클래스: .th_1st (예산), .th1_lnk (증평) — 첫 번째 a 가져옴. """ 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_=re.compile(r'th[_]?1?(?:_1st|_lnk)?'), recursive=False) or \ top_li.find('a', recursive=False) if not a1: continue D = clean_text(a1.get_text()) # div.item wrapper 있을 수도 없을 수도 item = top_li.find('div', class_='item', recursive=False) d2_ul = (item.find('ul', class_='depth2_ul') if item else None) or \ top_li.find('ul', class_='depth2_ul', recursive=False) 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_jincheon_recursive(soup, base): """진천군: div.sitemap > ul > li > a + div > ul > li > a + div > ul > ... 재귀.""" rows = [] container = soup.select_one('div.sitemap') if not container: return rows top_ul = container.find('ul', recursive=False) if not top_ul: return rows def walk(ul, 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()) if not text or text == '메뉴명이 없습니다.': continue href = extract_href(a) path = base_path + [(text, href)] next_div = li.find('div', recursive=False) if next_div: next_ul = next_div.find('ul', recursive=False) if next_ul: out.append({'path': list(path), 'href': href}) walk(next_ul, path, out) continue out.append({'path': list(path), 'href': href}) tmp = [] walk(top_ul, [], tmp) cols = 'DEFGHIJ' for item in tmp: p = item['path'] row = {c: '' for c in cols} row['href'] = item['href'] for i, (t, _) in enumerate(p): col = cols[i] if i < len(cols) else 'J' row[col] = t rows.append(row) return rows def parse_cheongju_sitemap(soup, base): """청주시: div#sitemap > div.site_map_col > div.sitemap_box > h3 > a (D) + ul.sm2depth > li > a (E) + ul.sm3depth > li > a (F). 별도의 '인트로' sitemap_box 는 D만 있고 ul.sm2depth 단순한 외부 링크 묶음 — 그대로 둠. """ rows = [] container = soup.select_one('div#sitemap') if not container: return rows for box in container.select('div.sitemap_box'): h3 = box.find('h3') h3_a = h3.find('a') if h3 else None D = clean_text(h3_a.get_text() if h3_a else (h3.get_text() if h3 else '')) D_href = extract_href(h3_a) if h3_a else '' sm2 = box.find('ul', class_='sm2depth') if not sm2: rows.append({'D': D, 'href': D_href, 'E': '', 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}) continue for li2 in sm2.find_all('li', recursive=False): a2 = li2.find('a', recursive=False) if not a2: continue E = clean_text(a2.get_text()) E_href = extract_href(a2) sm3 = li2.find('ul', class_='sm3depth', recursive=False) if not sm3: 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 sm3.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 def parse_chungju_sitemap(soup, base): """충주시: div#sitemap > div.site_map_col > div.sitemap_box > h3.h0 > a (D) + ul > li > a.h4 (E) + ul.bu > li > a (F).""" rows = [] container = soup.select_one('div#sitemap') if not container: return rows for box in container.select('div.sitemap_box'): h3 = box.find('h3') h3_a = h3.find('a') if h3 else None D = clean_text(h3_a.get_text() if h3_a else (h3.get_text() if h3 else '')) D_href = extract_href(h3_a) if h3_a else '' e_ul = box.find('ul', recursive=False) if not e_ul: rows.append({'D': D, 'href': D_href, 'E': '', 'F': '', 'G': '', 'H': '', 'I': '', 'J': ''}) continue for e_li in e_ul.find_all('li', recursive=False): a_E = e_li.find('a', class_='h4', recursive=False) or e_li.find('a', recursive=False) if not a_E: continue E = clean_text(a_E.get_text()) E_href = extract_href(a_E) f_ul = e_li.find('ul', class_='bu', recursive=False) if not f_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 f_li in f_ul.find_all('li', recursive=False): f_a = f_li.find('a', recursive=False) if not f_a: continue F = clean_text(f_a.get_text()) rows.append({'D': D, 'E': E, 'F': F, 'href': extract_href(f_a), 'G': '', 'H': '', 'I': '', 'J': ''}) return rows # ================================================================ # 사이트 설정 # ================================================================ SITES = [ { 'idx': 1, 'name': '괴산군', 'base': 'https://www.goesan.go.kr', 'sitemap': 'https://www.goesan.go.kr/www/sitemap.do?key=28', 'sheet': '01_괴산군', 'parser': parse_depth1_chungbuk, 'domain': 'goesan.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\1.괴산군', 'weak_ssl': False, }, { 'idx': 2, 'name': '단양군', 'base': 'https://www.danyang.go.kr', 'sitemap': 'https://www.danyang.go.kr/dy21/98', 'sheet': '02_단양군', 'parser': parse_danyang_ld, 'domain': 'danyang.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\2.단양군', 'weak_ssl': False, }, { # 보은군 — www.boeun.go.kr DNS 차단, apex boeun.go.kr 는 정상(2026-05-30 재확인) 'idx': 3, 'name': '보은군', 'base': 'https://boeun.go.kr', 'sitemap': 'https://boeun.go.kr/www/sitemap.do?key=1323', 'sheet': '03_보은군', 'parser': parse_depth1_chungbuk, 'domain': 'boeun.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\3.보은군', 'weak_ssl': False, }, { 'idx': 4, 'name': '영동군', 'base': 'https://www.yd21.go.kr', 'sitemap': 'https://www.yd21.go.kr/kr/html/guide/0701.html', 'sheet': '04_영동군', 'parser': parse_dl_dt_dd, 'domain': 'yd21.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\4.영동군', 'weak_ssl': True, }, { 'idx': 5, 'name': '옥천군', 'base': 'https://www.oc.go.kr', 'sitemap': 'https://www.oc.go.kr/www/sub.do?key=121', 'sheet': '05_옥천군', 'parser': parse_depth1_chungbuk, 'domain': 'oc.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\5.옥천군', 'weak_ssl': False, }, { 'idx': 6, 'name': '음성군', 'base': 'https://www.eumseong.go.kr', 'sitemap': 'https://www.eumseong.go.kr/www/sub.do?key=722', 'sheet': '06_음성군', 'parser': parse_depth1_chungbuk, 'domain': 'eumseong.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\6.음성군', 'weak_ssl': False, }, { 'idx': 7, 'name': '제천시', 'base': 'https://www.jecheon.go.kr', 'sitemap': 'https://www.jecheon.go.kr/www/sitemap.do?key=553', 'sheet': '07_제천시', 'parser': parse_depth1_chungbuk, 'domain': 'jecheon.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\7.제천시', 'weak_ssl': False, }, { 'idx': 8, 'name': '증평군', 'base': 'https://www.jp.go.kr', 'sitemap': 'https://www.jp.go.kr/kor/sitemap_11.do', 'sheet': '08_증평군', 'parser': parse_yesan_depth, 'domain': 'jp.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\8.증평군', 'weak_ssl': False, }, { 'idx': 9, 'name': '진천군', 'base': 'https://www.jincheon.go.kr', 'sitemap': 'https://www.jincheon.go.kr/home/sub.do?menukey=445', 'sheet': '09_진천군', 'parser': parse_jincheon_recursive, 'domain': 'jincheon.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\9.진천군', 'weak_ssl': False, }, { 'idx': 10, 'name': '청주시', 'base': 'https://www.cheongju.go.kr', 'sitemap': 'https://www.cheongju.go.kr/www/sitemap.do?key=589', 'sheet': '10_청주시', 'parser': parse_cheongju_sitemap, 'domain': 'cheongju.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\10.청주시', 'weak_ssl': False, }, { 'idx': 11, 'name': '충주시', 'base': 'https://www.chungju.go.kr', 'sitemap': 'https://www.chungju.go.kr/www/sub.do?key=692', 'sheet': '11_충주시', 'parser': parse_chungju_sitemap, 'domain': 'chungju.go.kr', 'folder': r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청북도\11.충주시', 'weak_ssl': False, }, ] # ================================================================ # 엑셀 생성 (공통) # ================================================================ 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: sess = make_session(weak_ssl=site.get('weak_ssl', False)) html = fetch_html(site['sitemap'], session=sess) 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()