DB_JOB/작업파일/_스크립트/_tab_expand_cms.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

325 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
충남표준CMS 본문 콘텐츠탭 펼치기 전개(서천식 풀리빌드). 범용·읽기/리빌드 분리.
사용:
dry: python _tab_expand_cms.py --xlsx <경로> --dom <도메인> --org <기관명> [--sel basic_tab,tab-ul]
write: 위 + --write -> 같은 폴더에 _<원본명>_NEW.xlsx 생성(원본 무수정)
규칙(펼치기): 페이지탭→별도행 / 게시판탭→행+M=건수 / 인페이지#앵커탭→랜딩 M=탭수(행X) / 외부=사이트.
랜딩URL과 같은 탭=랜딩행 재사용(G=탭명), 그 외 탭=신규행. 이미 시트에 있는 탭(covered)은 스킵.
"""
import openpyxl, requests, sys, re, json, os, argparse
from copy import copy
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
from openpyxl.utils import get_column_letter
from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib3
urllib3.disable_warnings()
sys.stdout.reconfigure(encoding='utf-8')
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
DEFAULT_SEL = ['basic_tab', 'tab-ul', 'slave_tab', 'tab_menu', 'tab_button',
'tabmenu', 'sub_tab', 'con_tab', 'tab_wrap', 'fiexd_tab', 'basic_tab3']
BAD_TOKENS = {'table', 'tablewrap', 'response', 'grap', 'no-more-tables',
'depth', 'side', 'location', 'gnb', 'lnb', 'snb', 'allmenu', 'footer'}
NAV_ANCESTORS = ('header', 'gnb', 'lnb', 'snb', 'nav', 'footer', 'top', 'aside', 'allmenu', 'sitemap')
def make_norm(dom):
def norm(u):
if not u: return ''
u = u.split('#')[0].rstrip('/')
return re.sub(r'^https?://(www\.)?' + re.escape(dom).replace(r'\.', r'\.'), '', u)
return norm
def in_nav(el):
p = el
for _ in range(8):
p = p.parent
if p is None: break
idc = ((p.get('id') or '') + ' ' + ' '.join(p.get('class') or [])).lower()
if any(k in idc for k in NAV_ANCESTORS): return True
return False
def is_cms_tab(el, sel):
cls = ' '.join(el.get('class') or [])
if not cls: return False
if any(b in cls.lower() for b in BAD_TOKENS): return False
if not any(re.search(r'(^|\s)' + re.escape(t) + r'(\d|\s|$)', cls + ' ') for t in sel):
return False
if in_nav(el): return False
return True
def classify(a, href, dom):
target = (a.get('target') or ''); cls = ' '.join(a.get('class') or []).lower()
host = urlparse(href).netloc.lower(); h = href.split('#')[0]
if not h or href.strip().startswith('#') or href.strip().lower().startswith('javascript'):
return 'anchor'
if target == '_blank' or 'link_3th' in cls or (host and dom not in host):
return 'site'
hl = h.lower()
if 'selectboardlist' in hl or '/bbs/' in hl or 'bbsmstr' in hl or '/cop/bbs/' in hl:
return 'bbs'
return 'page'
def total_of(html):
txt = BeautifulSoup(html, 'html.parser').get_text(' ')
for pat in [r'Total\s*[:]\s*([\d,]+)', r'\s*([\d,]+)\s*건', r'게시물\s*[:]?\s*([\d,]+)',
r'전체\s*[:]?\s*([\d,]+)\s*건']:
m = re.search(pat, txt)
if m: return int(m.group(1).replace(',', ''))
return None
def build_plan(xlsx, dom, sel, workers=16):
wb = openpyxl.load_workbook(xlsx); ws = wb.active
norm = make_norm(dom)
rows = []
for r in range(3, ws.max_row + 1):
c = ws.cell(r, 11); L = ws.cell(r, 12).value
u = c.hyperlink.target if c.hyperlink else (c.value if isinstance(c.value, str) and c.value.startswith('http') else None)
if u: rows.append({'r': r, 'url': u, 'L': L, 'F': ws.cell(r, 6).value, 'G': ws.cell(r, 7).value})
sheet_norm = set(norm(x['url']) for x in rows)
targets = [x for x in rows if x['L'] in ('페이지', '게시판') and dom in urlparse(x['url']).netloc]
uniq = {}
for x in targets: uniq.setdefault(norm(x['url']), x)
sess = requests.Session(); sess.headers.update(UA)
def fetch(u):
try:
rr = sess.get(u, timeout=25, verify=False); rr.encoding = rr.apparent_encoding or 'utf-8'; return u, rr.text
except Exception: return u, None
htmlcache = {}
with ThreadPoolExecutor(max_workers=workers) as ex:
for fu in as_completed([ex.submit(fetch, x['url']) for x in uniq.values()]):
u, h = fu.result(); htmlcache[u] = h
groups = []
for x in uniq.values():
html = htmlcache.get(x['url'])
if not html: continue
soup = BeautifulSoup(html, 'html.parser')
found = None
for el in soup.find_all(['ul', 'div']):
if not is_cms_tab(el, sel): continue
lis = [li for li in el.find_all('li') if li.find('a')]
if len(lis) < 2: continue
tabs = []
for li in lis:
a = li.find('a'); href = urljoin(x['url'], a.get('href', '').strip())
tabs.append({'label': ' '.join(li.get_text().split()), 'href': href,
'kind': classify(a, href, dom), 'norm': norm(href)})
pb = [t for t in tabs if t['kind'] in ('page', 'bbs')]
anchors = [t for t in tabs if t['kind'] == 'anchor']
# 인페이지 판정: page/bbs 탭의 '서로 다른 URL' 수가 2 미만이면(=같은 .do#앵커로 수렴)
# 행으로 펼치지 말고 랜딩 M=탭수. (순수 # 앵커탭 + sub.do#anchor 동일페이지탭 모두 커버)
distinct_pb = {t['norm'] for t in pb}
if len(distinct_pb) < 2:
if len(anchors) + len(pb) >= 2:
found = {'row': x['r'], 'F': x['F'], 'G': x['G'], 'url': x['url'], 'norm': norm(x['url']),
'inpage': True, 'anchor_n': len(tabs)}
break
continue
# 실제 다중페이지 탭: 외부 site 탭은 별도행(L=사이트) 유지. norm 중복은 제거(같은URL 1회만).
keep = []; seen_n = set()
for t in tabs:
if t['kind'] == 'anchor': continue
if t['norm'] in seen_n: continue
seen_n.add(t['norm']); keep.append(t)
found = {'row': x['r'], 'F': x['F'], 'G': x['G'], 'url': x['url'], 'norm': norm(x['url']),
'inpage': False, 'tabs': keep}
break
if found: groups.append(found)
# board totals (재시도 포함, 동시성 낮춰 빌드시 누락 방지)
board_urls = list({t['href'] for g in groups if not g['inpage'] for t in g['tabs'] if t['kind'] == 'bbs'})
def fetch_total(u):
for attempt in range(3):
try:
rr = sess.get(u, timeout=30, verify=False); rr.encoding = rr.apparent_encoding or 'utf-8'
t = total_of(rr.text)
if t is not None: return t
# 본문에 게시판 시그니처 없으면 진짜 0/비보드일 수 있음 → 0
return 0 if BeautifulSoup(rr.text, 'html.parser').get_text() else None
except Exception:
continue
return None
btot = {}
with ThreadPoolExecutor(max_workers=6) as ex:
futs = {ex.submit(fetch_total, u): u for u in board_urls}
for fu in as_completed(futs):
btot[futs[fu]] = fu.result()
for g in groups:
if g['inpage']: continue
for t in g['tabs']:
if t['kind'] == 'bbs': t['total'] = btot.get(t['href'])
return ws, groups, sheet_norm, norm
def kind_fields(kind, total=None):
if kind == 'site': return dict(L='사이트', M=None, N=None, O=None)
if kind == 'bbs': return dict(L='게시판', M=(total if total is not None else 0), N='어문', O='미부착')
return dict(L='페이지', M=1, N='어문', O='미부착')
def rebuild(xlsx, groups, sheet_norm, norm, org, outpath):
wb = openpyxl.load_workbook(xlsx); ws = wb.active
NCOL = 27
DATA_LO = 3
# 실데이터 경계: B/C/D/E/F/G/L 중 하나라도 값 있는 마지막 행.
# (gb.go.kr 템플릿 잔재=K하이퍼링크만 남은 유령행은 제외)
SIG = (2, 3, 4, 5, 6, 7, 12)
DATA_HI = DATA_LO
for r in range(DATA_LO, ws.max_row + 1):
if any(ws.cell(r, c).value not in (None, '') for c in SIG):
DATA_HI = r
plan_by_row = {g['row']: g for g in groups}
# 전역위젯 가드: 같은 타깃 URL이 ≥3개 다른 랜딩그룹의 탭으로 반복 → nav 위젯(고정타깃)으로 보고 제외.
# (진짜 per-entity 콘텐츠탭은 랜딩마다 타깃 URL이 유니크하므로 1개 그룹에만 등장)
from collections import Counter as _C
_gt = _C()
for g in groups:
if g.get('inpage'): continue
for tn in {t['norm'] for t in g['tabs']}:
_gt[tn] += 1
GLOBAL_WIDGET = {tn for tn, c in _gt.items() if c >= 3}
def covering(col, r):
for m in ws.merged_cells.ranges:
if m.min_col <= col <= m.max_col and m.min_row <= r <= m.max_row:
return str(m)
return None
def keymap(col):
return {r: (covering(col, r) or f'{get_column_letter(col)}{r}') for r in range(DATA_LO, DATA_HI + 1)}
DKEY = keymap(4); EKEY = keymap(5); FKEY = keymap(6)
out = []; fgrp_id = 0; added_norms = set()
for r in range(DATA_LO, DATA_HI + 1):
base = {'tpl': r, 'over': {}, 'klink': None, 'dkey': DKEY[r], 'ekey': EKEY[r], 'fkey': FKEY[r], 'fgrp': None, 'ggrp': None}
out.append(base)
g = plan_by_row.get(r)
if not g: continue
if g.get('inpage'):
base['over']['M'] = g['anchor_n'] # 인페이지탭: 랜딩 M=탭수, 행X
continue
# 전역위젯 탭 제거(랜딩 자기 자신은 유지). 제거 후 page/board <2면 전개 안 함.
tabs = [t for t in g['tabs'] if t['norm'] not in GLOBAL_WIDGET or t['norm'] == g['norm']]
if sum(1 for t in tabs if t['kind'] in ('page', 'bbs')) < 2:
continue
# 신규로 추가할 탭: 랜딩과 같은 탭(매치)은 랜딩행 재사용, 나머지는 covered 아닌 것만 신규행
midx = next((i for i, t in enumerate(tabs) if t['norm'] == g['norm']), None)
if midx is not None:
base['over']['G'] = tabs[midx]['label']
others = [t for i, t in enumerate(tabs) if i != midx]
else:
t0 = tabs[0]
base['over'].update({'G': t0['label'], 'K': t0['href'], **kind_fields(t0['kind'], t0.get('total'))})
base['klink'] = t0['href']
added_norms.add(t0['norm'])
others = tabs[1:]
fgrp_id += 1
ffull = g['F']
base['fgrp'] = ('F', fgrp_id) if ffull is not None else None
for t in others:
if t['norm'] != g['norm'] and t['norm'] in sheet_norm:
continue # 이미 시트에 있음(covered) → 중복 방지
if t['norm'] in added_norms:
continue # 이번 리빌드에서 이미 추가한 URL → 중복 방지
added_norms.add(t['norm'])
nr = {'tpl': r, 'dkey': DKEY[r], 'ekey': EKEY[r], 'fkey': FKEY[r],
'over': {'C': org, 'F': None, 'G': t['label'], 'K': t['href'], **kind_fields(t['kind'], t.get('total'))},
'klink': t['href'], 'fgrp': (('F', fgrp_id) if ffull is not None else None), 'ggrp': None}
out.append(nr)
nwb = openpyxl.Workbook(); nws = nwb.active; nws.title = ws.title
for col, dim in ws.column_dimensions.items():
nws.column_dimensions[col].width = dim.width; nws.column_dimensions[col].hidden = dim.hidden
nws.sheet_format = copy(ws.sheet_format)
try: nws.freeze_panes = ws.freeze_panes
except Exception: pass
def copy_cell(srow, scol, drow, dcol, value='__keep__', link='__none__'):
s = ws.cell(srow, scol); d = nws.cell(drow, dcol)
if s.has_style:
d.font = copy(s.font); d.fill = copy(s.fill); d.border = copy(s.border)
d.alignment = copy(s.alignment); d.protection = copy(s.protection); d.number_format = s.number_format
d.value = s.value if value == '__keep__' else value
if link != '__none__':
if link: d.hyperlink = link
else:
if s.hyperlink is not None: d.hyperlink = copy(s.hyperlink)
return d
for r in (1, 2):
h = ws.row_dimensions[r].height
if h: nws.row_dimensions[r].height = h
for c in range(1, NCOL + 1): copy_cell(r, c, r, c)
L2C = {get_column_letter(c): c for c in range(1, NCOL + 1)}
for i, ro in enumerate(out):
drow = 3 + i; tpl = ro['tpl']
h = ws.row_dimensions[tpl].height
if h: nws.row_dimensions[drow].height = h
overcols = {L2C[k]: v for k, v in ro['over'].items()}
for c in range(1, NCOL + 1):
if c == 11 and ro['klink'] is not None:
copy_cell(tpl, c, drow, c, value=ro['over'].get('K', ws.cell(tpl, c).value), link=ro['klink'])
elif c in overcols:
copy_cell(tpl, c, drow, c, value=overcols[c], link='' if c == 11 else '__none__')
else:
copy_cell(tpl, c, drow, c)
nws.cell(drow, 2).value = i + 1
ro['drow'] = drow
for m in ws.merged_cells.ranges:
if m.max_row <= 2: nws.merge_cells(str(m))
def merge_runs(keyattr, col):
i = 0; n = len(out)
while i < n:
j = i
while j + 1 < n and out[j + 1][keyattr] == out[i][keyattr]: j += 1
if j > i:
nws.merge_cells(start_row=out[i]['drow'], start_column=col, end_row=out[j]['drow'], end_column=col)
i = j + 1
# D/E/F 병합: 원본 병합키(FKEY 포함)로 재구성 → 비-탭 원본 F병합도 보존,
# 탭 신규행은 랜딩 FKEY 상속이라 같은 F블록으로 자동 병합.
merge_runs('dkey', 4); merge_runs('ekey', 5); merge_runs('fkey', 6)
nwb.save(outpath)
return len(out)
if __name__ == '__main__':
ap = argparse.ArgumentParser()
ap.add_argument('--xlsx', required=True); ap.add_argument('--dom', required=True)
ap.add_argument('--org', required=True); ap.add_argument('--sel', default=','.join(DEFAULT_SEL))
ap.add_argument('--write', action='store_true')
a = ap.parse_args()
sel = [s.strip() for s in a.sel.split(',') if s.strip()]
ws, groups, sheet_norm, norm = build_plan(a.xlsx, a.dom, sel)
from collections import Counter as _C
_gt = _C()
for g in groups:
if g.get('inpage'): continue
for tn in {t['norm'] for t in g['tabs']}: _gt[tn] += 1
WID = {tn for tn, c in _gt.items() if c >= 3}
exp = [g for g in groups if not g['inpage']]
inp = [g for g in groups if g['inpage']]
addcnt = 0; skipped_w = 0
print(f"== {a.org} == 탭그룹 {len(groups)} (전개 {len(exp)} / 인페이지 {len(inp)}) / 전역위젯URL {len(WID)}")
for g in sorted(exp, key=lambda g: -len(g['tabs'])):
tabs = [t for t in g['tabs'] if t['norm'] not in WID or t['norm'] == g['norm']]
if sum(1 for t in tabs if t['kind'] in ('page', 'bbs')) < 2:
skipped_w += 1; continue
new = [t for t in tabs if t['norm'] == g['norm'] or t['norm'] not in sheet_norm]
matched = any(t['norm'] == g['norm'] for t in tabs)
add = len(new) - (1 if matched else 0)
addcnt += add
kinds = {}
for t in tabs: kinds[t['kind']] = kinds.get(t['kind'], 0) + 1
print(f" R{g['row']:>3} {str(g['F'])[:18]:18} tabs={len(tabs)} +{add} {kinds}")
for g in inp:
print(f" R{g['row']:>3} {str(g['F'])[:18]:18} [인페이지] M={g['anchor_n']}")
print(f"예상 신규행 +{addcnt} (전역위젯으로 제외된 그룹 {skipped_w})")
if a.write:
out = os.path.join(os.path.dirname(os.path.abspath(a.xlsx)),
'_' + os.path.splitext(os.path.basename(a.xlsx))[0] + '_NEW.xlsx')
n = rebuild(a.xlsx, groups, sheet_norm, norm, a.org, out)
print(f"SAVED {out} 데이터행={n}")