공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
351 lines
13 KiB
Python
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()
|