# -*- coding: utf-8 -*- """ CMS 프로파일 기반 탭 스캐너 (충남표준CMS 검증판). 범용탐지(_tab_scan)와 달리 '알려진 콘텐츠탭 셀렉터'만 봐서 내비 과탐 제거. covered() 규칙: 부모필터 URL(?cate_b_cd=b01)이 자식(?cate_b_cd=b01&cate_cd=10)으로 시트에 있으면 커버로 간주. """ import openpyxl, requests, sys, re, json from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse from concurrent.futures import ThreadPoolExecutor, as_completed from collections import Counter import urllib3 urllib3.disable_warnings() sys.stdout.reconfigure(encoding='utf-8') UA={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} # ---- 콘텐츠탭 셀렉터 카탈로그(여러 벤더 근거) ---- # 충남표준=tab-ul/basic_tab/slave_tab, 서산계=tab_menu/tab_button, 부여 사전공개=fiexd_tab TAB_TOKENS = {'tab-ul', 'basic_tab', 'slave_tab', 'tab_menu', 'tab_button', 'fiexd_tab', 'tabmenu', 'sub_tab', 'con_tab', 'contab', 'tab_wrap'} 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 norm_url(u): if not u: return '' u=u.split('#')[0].rstrip('/') return re.sub(r'^https?://(www\.)?','',u).lower() 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): toks=set(re.sub(r'\d+','',t).strip('_-') for t in (el.get('class') or [])) toks={t for t in toks if t} if any(b in ' '.join(el.get('class') or []).lower() for b in BAD_TOKENS): return False # 토큰 정확매칭(tab-ul/basic_tab/slave_tab). tab-ul은 하이픈 보존 필요 cls=' '.join(el.get('class') or []) if not any(re.search(r'(^|\s)'+re.escape(t)+r'(\d|\s|$)', cls+' ') for t in TAB_TOKENS): return False if in_nav(el): return False return True def covered(t_norm, sheet_norm): if t_norm in sheet_norm: return True for s in sheet_norm: if s.startswith(t_norm+'&') or s.startswith(t_norm+'/') or s.startswith(t_norm+'?'): return True return False def classify(a, href, domain): 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 domain not in host): return 'site' if 'selectboardlist' in h.lower() or '/bbs/' in h.lower() or 'bbsmstr' in h.lower(): return 'board' return 'page' def scan(xlsx, workers=16): wb=openpyxl.load_workbook(xlsx); ws=wb.active 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}) sheet_norm=set(norm_url(x['url']) for x in rows) dom=Counter(urlparse(x['url']).netloc.replace('www.','') for x in rows if urlparse(x['url']).netloc).most_common(1)[0][0] 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_url(x['url']), x) sess=requests.Session(); sess.headers.update(UA) def fetch(x): try: rr=sess.get(x['url'],timeout=20,verify=False); rr.encoding=rr.apparent_encoding or 'utf-8'; return x,rr.text except: return x,None page_groups=[]; inpage_groups=[] with ThreadPoolExecutor(max_workers=workers) as ex: for fu in as_completed([ex.submit(fetch,x) for x in uniq.values()]): x,html=fu.result() if not html: continue soup=BeautifulSoup(html,'html.parser') for el in soup.find_all(['ul','div']): if not is_cms_tab(el): 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(a.get_text().split())[:24],'href':href, 'kind':classify(a,href,dom),'norm':norm_url(href)}) pb=[t for t in tabs if t['kind'] in ('page','board')] anchors=[t for t in tabs if t['kind']=='anchor'] if len(anchors)>=2 and len(pb)<2: inpage_groups.append({'r':x['r'],'F':x['F'],'n':len(anchors)}); continue if len(pb)<2: continue missing=[t for t in pb if not covered(t['norm'], sheet_norm)] if missing: page_groups.append({'r':x['r'],'F':x['F'],'cls':' '.join(el.get('class') or []), 'tabs':len(pb),'missing':len(missing), 'ex':[m['label'] for m in missing[:4]]}) return {'xlsx':xlsx.split('\\')[-1].split('/')[-1],'dom':dom,'rows':len(rows),'pages':len(uniq), 'page_tab_groups':len(page_groups),'est_new':sum(g['missing'] for g in page_groups), 'inpage_groups':len(inpage_groups), 'top':sorted(page_groups,key=lambda g:-g['missing'])[:12]} if __name__=='__main__': out=scan(sys.argv[1]) print(json.dumps({k:v for k,v in out.items() if k!='top'}, ensure_ascii=False, indent=1)) for g in out['top']: print(f" R{g['r']:>3} {str(g['F'])[:16]:16} cls='{g['cls'][:20]}' tabs={g['tabs']} missing={g['missing']} ex={g['ex']}")