""" HTML 스타일 분석기 v3.0 HTML 요소를 분석하여 역할(Role)을 자동 분류 ✅ v3.0 변경사항: - 글벗 HTML 구조 완벽 지원 (.sheet, .body-content) - 머리말/꼬리말/페이지번호 제거 - 강력한 중복 콘텐츠 필터링 - 제목 계층 구조 정확한 인식 """ import re from bs4 import BeautifulSoup, Tag, NavigableString from dataclasses import dataclass from typing import List, Dict, Optional, Tuple, Set from enum import Enum class DocumentSection(Enum): """문서 섹션 유형""" COVER = "cover" # 표지 TOC = "toc" # 목차 CONTENT = "content" # 본문 @dataclass class StyledElement: """스타일이 지정된 요소""" role: str # 역할 (H1, BODY, TH 등) text: str # 텍스트 내용 tag: str # 원본 HTML 태그 html: str # 원본 HTML section: str # 섹션 (cover, toc, content) attributes: Dict # 추가 속성 (이미지 src 등) def __repr__(self): preview = self.text[:30] + "..." if len(self.text) > 30 else self.text return f"<{self.role}> {preview}" class StyleAnalyzer: """HTML 문서를 분석하여 역할 분류""" # 번호 패턴 정의 PATTERNS = { # 장 번호: "제1장", "제2장" "chapter": re.compile(r'^제\s*\d+\s*장'), # 1단계 제목: "1 ", "2 " (숫자+공백, 점 없음) "h1_num": re.compile(r'^(\d+)\s+[가-힣]'), # 대항목: "1.", "2." "h2_num": re.compile(r'^(\d+)\.\s'), # 중항목: "1.1 ", "1.2 " "h3_num": re.compile(r'^(\d+)\.(\d+)\s'), # 소항목: "1.1.1" "h4_num": re.compile(r'^(\d+)\.(\d+)\.(\d+)'), # 세부: "1)", "2)" "h5_paren": re.compile(r'^(\d+)\)\s*'), # 세세부: "(1)", "(2)" "h6_paren": re.compile(r'^\((\d+)\)\s*'), # 가나다: "가.", "나." "h4_korean": re.compile(r'^[가-하]\.\s'), # 가나다 괄호: "가)", "나)" "h5_korean": re.compile(r'^[가-하]\)\s'), # 원문자: "①", "②" "h6_circle": re.compile(r'^[①②③④⑤⑥⑦⑧⑨⑩]'), # 목록: "•", "-", "○" "list_bullet": re.compile(r'^[•\-○]\s'), # 페이지 번호 패턴: "- 1 -", "- 12 -" "page_number": re.compile(r'^-\s*\d+\s*-$'), # 꼬리말 패턴: "문서제목- 1 -" "footer_pattern": re.compile(r'.+[-–]\s*\d+\s*[-–]$'), } # 제거할 텍스트 패턴들 REMOVE_PATTERNS = [ re.compile(r'^-\s*\d+\s*-$'), # "- 1 -" re.compile(r'[-–]\s*\d+\s*[-–]\s*$'), # "문서제목- 1 -" re.compile(r'^\d+\s*×\s*\d+$'), # "643 × 236" (이미지 크기) re.compile(r'^\[이미지 없음:.*\]$'), # "[이미지 없음: xxx]" re.compile(r'^\[그림\s*\d+-\d+\]$'), # "[그림 1-1]" ] def __init__(self): self.elements: List[StyledElement] = [] self.current_section = DocumentSection.CONTENT self.seen_texts: Set[str] = set() # 중복 방지용 self.document_title = "" # 문서 제목 (꼬리말 제거용) def analyze(self, html: str) -> List[StyledElement]: """HTML 문서 분석하여 역할 분류된 요소 리스트 반환""" soup = BeautifulSoup(html, 'html.parser') self.elements = [] self.seen_texts = set() # 1. 전처리: 불필요한 요소 제거 self._preprocess(soup) # 2. 문서 제목 추출 (꼬리말 패턴 감지용) self._extract_document_title(soup) # 3. 섹션 감지 및 순회 self._detect_and_process_sections(soup) # 4. 후처리: 중복 및 불필요 요소 제거 self._postprocess() return self.elements def _preprocess(self, soup: BeautifulSoup): """HTML 전처리 - 불필요한 요소 제거""" print(" 🔧 HTML 전처리 중...") # 1. 스크립트/스타일 태그 제거 removed_count = 0 for tag in soup(['script', 'style', 'noscript', 'meta', 'link', 'head']): tag.decompose() removed_count += 1 if removed_count > 0: print(f" - script/style 등 {removed_count}개 제거") # 2. 머리말/꼬리말 영역 제거 (글벗 HTML 구조) header_footer_count = 0 for selector in ['.page-header', '.page-footer', '.header', '.footer', '[class*="header"]', '[class*="footer"]', '.running-header', '.running-footer']: for elem in soup.select(selector): # 실제 콘텐츠 헤더가 아닌 페이지 헤더만 제거 text = elem.get_text(strip=True) if self._is_header_footer_text(text): elem.decompose() header_footer_count += 1 if header_footer_count > 0: print(f" - 머리말/꼬리말 {header_footer_count}개 제거") # 3. 숨겨진 요소 제거 hidden_count = 0 for elem in soup.select('[style*="display:none"], [style*="display: none"]'): elem.decompose() hidden_count += 1 for elem in soup.select('[style*="visibility:hidden"], [style*="visibility: hidden"]'): elem.decompose() hidden_count += 1 # 4. #raw-container 외부의 .sheet 제거 (글벗 구조) raw_container = soup.find(id='raw-container') if raw_container: print(" - 글벗 구조 감지: #raw-container 우선 사용") # raw-container 외부의 모든 .sheet 제거 for sheet in soup.select('.sheet'): if not self._is_descendant_of(sheet, raw_container): sheet.decompose() def _extract_document_title(self, soup: BeautifulSoup): """문서 제목 추출 (꼬리말 패턴 감지용)""" # 표지에서 제목 찾기 cover = soup.find(id='box-cover') or soup.find(class_='box-cover') if cover: h1 = cover.find('h1') if h1: self.document_title = h1.get_text(strip=True) print(f" - 문서 제목 감지: {self.document_title[:30]}...") def _is_header_footer_text(self, text: str) -> bool: """머리말/꼬리말 텍스트인지 판단""" if not text: return False # 페이지 번호 패턴 if self.PATTERNS['page_number'].match(text): return True # "문서제목- 1 -" 패턴 if self.PATTERNS['footer_pattern'].match(text): return True # 문서 제목 + 페이지번호 조합 if self.document_title and self.document_title in text: if re.search(r'[-–]\s*\d+\s*[-–]', text): return True return False def _should_skip_text(self, text: str) -> bool: """건너뛸 텍스트인지 판단""" if not text: return True # 제거 패턴 체크 for pattern in self.REMOVE_PATTERNS: if pattern.match(text): return True # 머리말/꼬리말 체크 if self._is_header_footer_text(text): return True # 문서 제목만 있는 줄 (꼬리말에서 온 것) if self.document_title and text.strip() == self.document_title: # 이미 표지에서 처리했으면 스킵 if any(e.role == 'COVER_TITLE' and self.document_title in e.text for e in self.elements): return True return False def _is_descendant_of(self, element: Tag, ancestor: Tag) -> bool: """element가 ancestor의 자손인지 확인""" parent = element.parent while parent: if parent == ancestor: return True parent = parent.parent return False def _detect_and_process_sections(self, soup: BeautifulSoup): """섹션 감지 및 처리""" # 글벗 구조 (#raw-container) 우선 처리 raw = soup.find(id='raw-container') if raw: self._process_geulbeot_structure(raw) return # .sheet 구조 처리 (렌더링된 페이지) sheets = soup.select('.sheet') if sheets: self._process_sheet_structure(sheets) return # 일반 HTML 구조 처리 self._process_generic_html(soup) def _process_geulbeot_structure(self, raw: Tag): """글벗 HTML #raw-container 구조 처리""" print(" 📄 글벗 #raw-container 구조 처리 중...") # 표지 cover = raw.find(id='box-cover') if cover: print(" - 표지 섹션") self.current_section = DocumentSection.COVER self._process_cover(cover) # 목차 toc = raw.find(id='box-toc') if toc: print(" - 목차 섹션") self.current_section = DocumentSection.TOC self._process_toc(toc) # 요약 summary = raw.find(id='box-summary') if summary: print(" - 요약 섹션") self.current_section = DocumentSection.CONTENT self._process_content_element(summary) # 본문 content = raw.find(id='box-content') if content: print(" - 본문 섹션") self.current_section = DocumentSection.CONTENT self._process_content_element(content) def _process_sheet_structure(self, sheets: List[Tag]): """글벗 .sheet 페이지 구조 처리""" print(f" 📄 .sheet 페이지 구조 처리 중... ({len(sheets)}페이지)") for i, sheet in enumerate(sheets): # 페이지 내 body-content만 추출 body_content = sheet.select_one('.body-content') if body_content: self._process_content_element(body_content) else: # body-content가 없으면 머리말/꼬리말 제외하고 처리 for child in sheet.children: if isinstance(child, Tag): classes = child.get('class', []) class_str = ' '.join(classes) if classes else '' # 머리말/꼬리말 스킵 if any(x in class_str.lower() for x in ['header', 'footer']): continue self._process_content_element(child) def _process_generic_html(self, soup: BeautifulSoup): """일반 HTML 구조 처리""" print(" 📄 일반 HTML 구조 처리 중...") # 표지 cover = soup.find(class_=re.compile(r'cover|title-page|box-cover')) if cover: self.current_section = DocumentSection.COVER self._process_cover(cover) # 목차 toc = soup.find(class_=re.compile(r'toc|table-of-contents')) if toc: self.current_section = DocumentSection.TOC self._process_toc(toc) # 본문 self.current_section = DocumentSection.CONTENT main_content = soup.find('main') or soup.find('article') or soup.find('body') or soup for child in main_content.children: if isinstance(child, Tag): self._process_content_element(child) def _process_cover(self, cover: Tag): """표지 처리""" # H1 = 제목 h1 = cover.find('h1') if h1: text = h1.get_text(strip=True) if text and not self._is_duplicate(text): self.elements.append(StyledElement( role="COVER_TITLE", text=text, tag="h1", html=str(h1)[:200], section="cover", attributes={} )) # H2 = 부제목 h2 = cover.find('h2') if h2: text = h2.get_text(strip=True) if text and not self._is_duplicate(text): self.elements.append(StyledElement( role="COVER_SUBTITLE", text=text, tag="h2", html=str(h2)[:200], section="cover", attributes={} )) # P = 정보 for p in cover.find_all('p'): text = p.get_text(strip=True) if text and not self._is_duplicate(text): self.elements.append(StyledElement( role="COVER_INFO", text=text, tag="p", html=str(p)[:200], section="cover", attributes={} )) def _process_toc(self, toc: Tag): """목차 처리""" # UL/OL 기반 목차 for li in toc.find_all('li'): text = li.get_text(strip=True) if not text or self._is_duplicate(text): continue classes = li.get('class', []) class_str = ' '.join(classes) if classes else '' # 레벨 판단 (구체적 → 일반 순서!) if 'lvl-1' in class_str or 'toc-lvl-1' in class_str: role = "TOC_H1" elif 'lvl-2' in class_str or 'toc-lvl-2' in class_str: role = "TOC_H2" elif 'lvl-3' in class_str or 'toc-lvl-3' in class_str: role = "TOC_H3" elif self.PATTERNS['h4_num'].match(text): # 1.1.1 먼저! role = "TOC_H3" elif self.PATTERNS['h3_num'].match(text): # 1.1 그다음 role = "TOC_H2" elif self.PATTERNS['h2_num'].match(text): # 1. 그다음 role = "TOC_H1" else: role = "TOC_H1" self.elements.append(StyledElement( role=role, text=text, tag="li", html=str(li)[:200], section="toc", attributes={} )) def _process_content_element(self, element: Tag): """본문 요소 재귀 처리""" if not isinstance(element, Tag): return tag_name = element.name.lower() if element.name else "" classes = element.get('class', []) class_str = ' '.join(classes) if classes else '' # 머리말/꼬리말 클래스 스킵 if any(x in class_str.lower() for x in ['header', 'footer', 'page-num']): return # 테이블 특수 처리 if tag_name == 'table': self._process_table(element) return # 그림 특수 처리 if tag_name in ['figure', 'img']: self._process_figure(element) return # 텍스트 추출 text = self._get_direct_text(element) if text: # 건너뛸 텍스트 체크 if self._should_skip_text(text): pass # 자식은 계속 처리 elif not self._is_duplicate(text): role = self._classify_role(element, tag_name, classes, text) if role: self.elements.append(StyledElement( role=role, text=text, tag=tag_name, html=str(element)[:200], section=self.current_section.value, attributes=dict(element.attrs) if element.attrs else {} )) # 자식 요소 재귀 처리 (컨테이너 태그) if tag_name in ['div', 'section', 'article', 'aside', 'main', 'body', 'ul', 'ol', 'dl', 'blockquote']: for child in element.children: if isinstance(child, Tag): self._process_content_element(child) def _get_direct_text(self, element: Tag) -> str: """요소의 직접 텍스트만 추출 (자식 컨테이너 제외)""" # 제목 태그는 전체 텍스트 if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'caption']: return element.get_text(strip=True) # 컨테이너 태그는 직접 텍스트만 texts = [] for child in element.children: if isinstance(child, NavigableString): t = str(child).strip() if t: texts.append(t) return ' '.join(texts) def _is_duplicate(self, text: str) -> bool: """중복 텍스트인지 확인""" if not text: return True # 정규화 normalized = re.sub(r'\s+', ' ', text.strip()) # 짧은 텍스트는 중복 허용 (번호 등) if len(normalized) < 10: return False # 첫 50자로 체크 key = normalized[:50] if key in self.seen_texts: return True self.seen_texts.add(key) return False def _classify_role(self, element: Tag, tag: str, classes: List[str], text: str) -> Optional[str]: """요소의 역할 분류 ⚠️ 중요: 패턴 매칭은 반드시 구체적인 것 → 일반적인 것 순서로! 1.1.1 → 1.1 → 1. → 1 (1) → 1) 가) → 가. """ class_str = ' '.join(classes) if classes else '' # ============ 제목 태그 (HTML 태그 우선) ============ if tag == 'h1': return "H1" if tag == 'h2': return "H2" if tag == 'h3': return "H3" if tag == 'h4': return "H4" if tag == 'h5': return "H5" if tag == 'h6': return "H6" # ============ 본문 (p, div 등) - 번호 패턴으로 분류 ============ if tag in ['p', 'div', 'span']: # ------ 숫자.숫자 패턴 (구체적 → 일반 순서!) ------ # "1.1.1" 패턴 (가장 구체적 - 먼저 체크!) if self.PATTERNS['h4_num'].match(text): if len(text) < 100: return "H3" return "BODY" # "1.1 " 패턴 if self.PATTERNS['h3_num'].match(text): if len(text) < 100: return "H2" return "BODY" # "1." 패턴 if self.PATTERNS['h2_num'].match(text): if len(text) < 100: return "H1" return "BODY" # "1 가나다..." 패턴 (숫자+공백+한글) if self.PATTERNS['h1_num'].match(text): return "H1" # ------ 괄호 패턴 (구체적 → 일반 순서!) ------ # "(1)" 패턴 (괄호로 감싼 게 더 구체적 - 먼저 체크!) if self.PATTERNS['h6_paren'].match(text): if element.find('strong') or len(text) < 80: return "H5" return "BODY" # "1)" 패턴 if self.PATTERNS['h5_paren'].match(text): if element.find('strong') or len(text) < 80: return "H4" return "BODY" # ------ 한글 패턴 (구체적 → 일반 순서!) ------ # "가)" 패턴 (괄호가 더 구체적 - 먼저 체크!) if self.PATTERNS['h5_korean'].match(text): return "H5" # "가." 패턴 if self.PATTERNS['h4_korean'].match(text): return "H4" # ------ 특수 기호 패턴 ------ # "①②③" 패턴 if self.PATTERNS['h6_circle'].match(text): return "H6" # ------ 기타 ------ # 강조 박스 if any(x in class_str for x in ['highlight', 'box', 'note', 'tip']): return "HIGHLIGHT_BOX" # 일반 본문 return "BODY" # ============ 목록 ============ if tag == 'li': return "LIST_ITEM" # ============ 정의 목록 ============ if tag == 'dt': return "H5" if tag == 'dd': return "BODY" return "BODY" def _process_table(self, table: Tag): """테이블 처리 - 구조 데이터 포함""" # 캡션 caption = table.find('caption') caption_text = "" if caption: caption_text = caption.get_text(strip=True) if caption_text and not self._is_duplicate(caption_text): self.elements.append(StyledElement( role="TABLE_CAPTION", text=caption_text, tag="caption", html=str(caption)[:100], section=self.current_section.value, attributes={} )) # 🆕 표 구조 데이터 수집 table_data = {'rows': [], 'caption': caption_text} for tr in table.find_all('tr'): row = [] for cell in tr.find_all(['th', 'td']): cell_info = { 'text': cell.get_text(strip=True), 'is_header': cell.name == 'th', 'colspan': int(cell.get('colspan', 1)), 'rowspan': int(cell.get('rowspan', 1)), 'bg_color': self._extract_bg_color(cell), } row.append(cell_info) if row: table_data['rows'].append(row) # 🆕 TABLE 요소로 추가 (개별 TH/TD 대신) if table_data['rows']: self.elements.append(StyledElement( role="TABLE", text=f"[표: {len(table_data['rows'])}행]", tag="table", html=str(table)[:200], section=self.current_section.value, attributes={'table_data': table_data} )) def _extract_bg_color(self, element: Tag) -> str: """요소에서 배경색 추출""" style = element.get('style', '') # background-color 추출 match = re.search(r'background-color:\s*([^;]+)', style) if match: return self._normalize_color(match.group(1)) # bgcolor 속성 bgcolor = element.get('bgcolor', '') if bgcolor: return self._normalize_color(bgcolor) return '' def _process_figure(self, element: Tag): """그림 처리""" img = element.find('img') if element.name == 'figure' else element if img and img.name == 'img': src = img.get('src', '') alt = img.get('alt', '') if src: # src가 있을 때만 추가 self.elements.append(StyledElement( role="FIGURE", text=alt or "이미지", tag="img", html=str(img)[:100], section=self.current_section.value, attributes={"src": src, "alt": alt} )) # 캡션 if element.name == 'figure': figcaption = element.find('figcaption') if figcaption: text = figcaption.get_text(strip=True) if text and not self._should_skip_text(text): self.elements.append(StyledElement( role="FIGURE_CAPTION", text=text, tag="figcaption", html=str(figcaption)[:100], section=self.current_section.value, attributes={} )) def _postprocess(self): """후처리: 불필요 요소 제거""" print(f" 🧹 후처리 중... (처리 전: {len(self.elements)}개)") filtered = [] for elem in self.elements: # 빈 텍스트 제거 if not elem.text or not elem.text.strip(): continue # 머리말/꼬리말 텍스트 제거 if self._is_header_footer_text(elem.text): continue # 제거 패턴 체크 skip = False for pattern in self.REMOVE_PATTERNS: if pattern.match(elem.text.strip()): skip = True break if not skip: filtered.append(elem) self.elements = filtered print(f" - 처리 후: {len(self.elements)}개") def get_role_summary(self) -> Dict[str, int]: """역할별 요소 수 요약""" summary = {} for elem in self.elements: summary[elem.role] = summary.get(elem.role, 0) + 1 return dict(sorted(summary.items())) def extract_css_styles(self, html: str) -> Dict[str, Dict]: """ HTML에서 역할별 CSS 스타일 추출 Returns: {역할: {font_size, color, bold, ...}} """ soup = BeautifulSoup(html, 'html.parser') role_styles = {} #

1 DX 개요와 기본 개념

1.1 측량 DX 프레임

1.1.1 측량 DX 발전 단계

1) Digitization 정의

본문 내용입니다. 이것은 충분히 긴 텍스트로 본문으로 인식되어야 합니다.

(1) 단계별 정의 및 진화

측량 기술의 발전은 장비의 변화와 성과물의 차원에 따라 구분된다.

① 첫 번째 항목

표 1. 데이터 비교
구분내용
항목1설명1
""" analyzer = StyleAnalyzer() elements = analyzer.analyze(test_html) print("\n" + "="*60) print("분석 결과") print("="*60) for elem in elements: print(f" {elem.role:18} | {elem.section:7} | {elem.text[:50]}") print("\n" + "="*60) print("역할 요약") print("="*60) for role, count in analyzer.get_role_summary().items(): print(f" {role}: {count}")