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

351 lines
13 KiB
Python

"""본문 탭(서브내비) → 카테고리 하위 확장.
규칙(공주시 기준, 일반화):
본문에서 '탭 UL'을 찾아, 아래 조건을 모두 만족하는 탭 그룹만 하위 카테고리로 확장한다.
1) UL(또는 직계 div)의 class 에 탭 패턴(tab-ul 등)이 있다.
2) 탭이 2개 이상이고, 모든 탭 href 가 '실제 페이지 링크'다.
(#anchor 인페이지 탭, javascript:, 빈 href, 쿼리스트링만 다른 필터 탭은 제외)
3) 현재 페이지 URL 이 탭 URL 집합에 포함된다(= 자기 자신의 탭 그룹).
4) 탭 중 '엑셀에 아직 없는 URL'이 1개 이상 있다(이미 사이트맵에 다 있으면 상위 nav → 제외).
확장 방식(=사용자 지시: 탭들은 한 단계 아래 컬럼으로):
- 원래 행의 leaf 컬럼(E~J 중 가장 깊은 값)을 부모(카테고리)로 두고,
탭들을 leaf+1 컬럼에 탭 순서대로 채운다.
- 현재 페이지와 같은 URL의 탭 = 원래 행을 재사용(L~T 기존 데이터 보존, G만 탭명 기입).
- 나머지 탭 = 신규 행(L~T 비움, Phase 2~4 별도 수행 대상).
사용법:
python -X utf8 _tab_expand.py <엑셀경로> <BASE_URL> <도메인키워드> [--write]
예) python -X utf8 _tab_expand.py 충청남도/2.공주시/충청남도_공주시.xlsx https://www.gongju.go.kr gongju.go.kr
(--write 없으면 계획만 출력 / 있으면 백업 후 실제 기입)
"""
import re
import sys
import shutil
import warnings
from copy import copy
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urljoin, urlsplit, urlunsplit
import ssl
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
from openpyxl.utils import get_column_letter
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)
SESSION = requests.Session()
SESSION.headers.update(H)
TAB_CLASS_PATS = ['tab-ul', 'tab_ul', 'tabmenu', 'tab-menu', 'tab_menu',
'tablist', 'tab-list', 'tab_list', 'subtab', 'sub-tab', 'sub_tab']
MAXCOL = 27 # AA
CAT_COLS = [5, 6, 7, 8, 9, 10] # E F G H I J
HEADER_MERGES = {'B1:R1', 'S1:W1', 'Y1:AA1'}
def norm_url(u):
"""쿼리/프래그먼트 제거 + 끝 슬래시 정리한 비교용 키."""
if not u:
return ''
s = urlsplit(u)
path = s.path.rstrip('/')
return urlunsplit((s.scheme, s.netloc, path, '', '')).lower()
def abs_url(href, base):
if not href:
return ''
href = href.strip()
if href.startswith(('javascript:', '#')):
return ''
if href.startswith(('http://', 'https://')):
return href
return urljoin(base.rstrip('/') + '/', href.lstrip('/'))
def has_tab_class(el):
cls = ' '.join(el.get('class') or []).lower()
return any(p in cls for p in TAB_CLASS_PATS), cls
def find_tab_groups(soup, base):
"""조건 1·2 만족하는 탭 그룹 후보 반환: [(cls, [(label, abs_url),...]), ...]"""
out = []
seen = set()
for ul in soup.find_all('ul'):
ok, cls = has_tab_class(ul)
if not ok:
continue # UL 자신에 탭 클래스 필요 (쿼리필터·무클래스 ul 배제)
links = []
real = True
for a in ul.find_all('a'):
t = a.get_text(strip=True).replace('\xa0', '').strip()
raw = (a.get('href') or '').strip()
au = abs_url(raw, base)
if not t:
continue
if not au: # #anchor / javascript / 빈 href → 인페이지 탭
real = False
break
links.append((t, au))
if not real or len(links) < 2:
continue
key = tuple(nu for _, (nu) in [(t, u) for t, u in links])
if key in seen:
continue
seen.add(key)
out.append((cls, links))
return out
def fetch(r, url):
try:
resp = SESSION.get(url, timeout=15, verify=False)
# 바이트로 받아 BeautifulSoup가 meta charset으로 인코딩 판별 (apparent_encoding 오판 방지)
return r, url, resp.content, None
except Exception as e:
return r, url, None, str(e)[:60]
def load_flat(ws):
"""병합값을 채워넣어 행별 완전 데이터로 평탄화. 반환: rows(list of dict), 스타일/하이퍼링크 캡처."""
# 1) 병합값 채우기 (헤더 제외, 데이터 컬럼)
for mr in list(ws.merged_cells.ranges):
s = str(mr)
if s in HEADER_MERGES:
continue
top = ws.cell(mr.min_row, mr.min_col).value
ws.unmerge_cells(s)
for rr in range(mr.min_row, mr.max_row + 1):
for cc in range(mr.min_col, mr.max_col + 1):
if ws.cell(rr, cc).value in (None, ''):
ws.cell(rr, cc).value = top
rows = []
for r in range(3, ws.max_row + 1):
# 빈 행 스킵
if all(ws.cell(r, c).value in (None, '') for c in range(2, MAXCOL + 1)):
continue
vals = {c: ws.cell(r, c).value for c in range(1, MAXCOL + 1)}
styles = {}
for c in range(1, MAXCOL + 1):
sc = ws.cell(r, c)
styles[c] = (copy(sc.font), copy(sc.fill), copy(sc.border),
copy(sc.alignment), sc.number_format, copy(sc.protection))
hl = ws.cell(r, 11).hyperlink
rows.append({'src': r, 'vals': vals, 'styles': styles,
'hyperlink': hl.target if hl else None})
return rows
def leaf_col(vals):
"""E~J 중 값이 있는 가장 깊은 컬럼 인덱스."""
deep = 5
for c in CAT_COLS:
if vals.get(c) not in (None, ''):
deep = c
return deep
def main():
xlsx, base, domain = sys.argv[1], sys.argv[2], sys.argv[3]
write = '--write' in sys.argv
if '--weak-ssl' in sys.argv:
SESSION.mount('https://', WeakSSLAdapter())
wb = openpyxl.load_workbook(xlsx)
ws = wb.active
rows = load_flat(ws)
existing = set()
for row in rows:
u = row['vals'].get(11)
if isinstance(u, str) and u.startswith('http'):
existing.add(norm_url(u))
# 같은 도메인 행 fetch
targets = [(row['src'], row['vals'].get(11)) for row in rows
if isinstance(row['vals'].get(11), str) and domain in row['vals'].get(11)]
html_by_src = {}
with ThreadPoolExecutor(max_workers=8) as ex:
futs = [ex.submit(fetch, r, u) for r, u in targets]
for fut in as_completed(futs):
r, url, html, err = fut.result()
if html:
html_by_src[r] = (url, html)
# 행별 확장 계획
plan = {} # src_row -> ordered [(label, url, is_existing)]
for row in rows:
src = row['src']
if src not in html_by_src:
continue
url, html = html_by_src[src]
cur = norm_url(url)
soup = BeautifulSoup(html, 'html.parser')
groups = find_tab_groups(soup, base)
chosen = None
for cls, links in groups:
tab_norms = [norm_url(u) for _, u in links]
if cur not in tab_norms:
continue # 조건3: 자기 탭그룹만
new_cnt = sum(1 for n in tab_norms if n not in existing)
if new_cnt < 1:
continue # 조건4: 신규 0 → 상위nav, 제외
chosen = links
break
if chosen:
ch = []
for label, u in chosen:
ch.append((label, u, norm_url(u) == cur))
plan[src] = ch
# 계획 출력
print(f'=== 탭 확장 계획: {len(plan)}개 그룹 ===\n')
total_new = 0
for src in sorted(plan):
row = next(r for r in rows if r['src'] == src)
v = row['vals']
lc = leaf_col(v)
childc = lc + 1
ev = v.get(5) or ''
fv = v.get(6) or ''
print(f'[행{src}] E={ev} F={fv} '
f'(leaf={get_column_letter(lc)} → 자식={get_column_letter(childc)})')
for label, u, exist in plan[src]:
tag = '재사용' if exist else '신규+'
if not exist:
total_new += 1
print(f' [{tag}] {label} -> {u}')
print()
print(f'신규 추가 행: {total_new}개 / 기존 {len(rows)}행 → 최종 {len(rows)+total_new}')
if not write:
print('\n(계획만 출력. 실제 기입하려면 --write)')
return
# ===== 실제 기입 =====
backup = xlsx.replace('.xlsx', '_backup_tab전.xlsx')
shutil.copy(xlsx, backup)
print(f'\n백업: {backup}')
# 새 평탄 행 목록 구성
out_rows = [] # 각 원소 = {'vals':..., 'style_src':row_dict, 'url':..}
for row in rows:
src = row['src']
if src in plan:
v = row['vals']
lc = leaf_col(v)
childc = lc + 1
for label, u, exist in plan[src]:
nv = dict(v)
# leaf 보다 깊은 컬럼 초기화 후 자식 컬럼에 탭명
for c in CAT_COLS:
if c > lc:
nv[c] = None
nv[childc] = label
if exist:
nv[11] = v.get(11) # 기존 URL/데이터 유지
out_rows.append({'vals': nv, 'style': row, 'url': nv.get(11)})
else:
for c in range(12, MAXCOL + 1):
nv[c] = None # L~T 비움 (신규)
nv[11] = u
nv[19] = None
out_rows.append({'vals': nv, 'style': row, 'url': u})
else:
out_rows.append({'vals': dict(row['vals']), 'style': row, 'url': row['vals'].get(11)})
# 데이터 영역 클리어
for r in range(3, ws.max_row + 1):
for c in range(1, MAXCOL + 1):
ws.cell(r, c).value = None
ws.cell(r, c).hyperlink = None
START = 3
for i, orow in enumerate(out_rows):
r = START + i
sty = orow['style']['styles']
for c in range(1, MAXCOL + 1):
cell = ws.cell(r, c)
f, fl, bd, al, nf, pr = sty[c]
cell.font = copy(f); cell.fill = copy(fl); cell.border = copy(bd)
cell.alignment = copy(al); cell.number_format = nf; cell.protection = copy(pr)
v = orow['vals']
for c in range(1, MAXCOL + 1):
ws.cell(r, c).value = v.get(c)
ws.cell(r, 2).value = i + 1 # B 순번 재부여
END = START + len(out_rows) - 1
# D/E/F 재병합 (G는 leaf라 병합 안 함)
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=()):
cur_val = ws.cell(START, col_idx).value
cur_grp = tuple(ws.cell(START, g).value for g in group_cols)
run_start = START
runs = []
for r in range(START + 1, END + 1):
val = ws.cell(r, col_idx).value
grp = tuple(ws.cell(r, gg).value for gg in group_cols)
if val == cur_val and grp == 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 = val, grp, 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
merge_runs('F', 6, group_cols=(4, 5))
merge_runs('E', 5, group_cols=(4,))
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
# K 하이퍼링크 재설정
for r in range(START, END + 1):
cell = ws.cell(r, 11)
u = cell.value
if 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
wb.save(xlsx)
print(f'저장 완료: {xlsx} (총 {len(out_rows)}행)')
if __name__ == '__main__':
main()