DB_JOB/_스크립트/_chungbuk_phase1_all.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

661 lines
27 KiB
Python

"""충청북도 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'<meta[^>]*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()