""" 文本处理器模块 - 重构版 实现全局编号系统,确保段落精确对应 + 清理HTML标签 """ import re from bs4 import BeautifulSoup from typing import List, Dict from loguru import logger class TextProcessor: """文本处理器 - 简化版,专注核心功能""" def __init__(self, config: Dict): """ 初始化文本处理器 Args: config: 配置字典 """ self.config = config # 不再使用最小长度限制,只要有内容就提取 self.chunk_size = config['translation']['chunk_size'] self._global_id_counter = 0 logger.info(f"文本处理器初始化: chunk_size={self.chunk_size}, 无最小长度限制") @staticmethod def get_valid_text_elements(soup) -> List: """ 获取有效的文本元素列表,自动过滤嵌套容器 (静态方法,供Builder共用,确保遍历顺序一致) Args: soup: BeautifulSoup对象 Returns: 过滤后的元素列表 """ # 定义关注的标签 tags = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'li', 'td'] # 1. 获取所有候选元素 all_candidates = soup.find_all(tags) # 2. 转换为集合以提高查找速度 candidate_set = set(all_candidates) final_elements = [] for element in all_candidates: # 3. 检查当前元素是否包含其他候选元素 # 如果包含,说明它是父容器,应该跳过,让子元素去被处理 has_candidate_children = False # 只查找直接子级或后代中的候选标签 descendants = element.find_all(tags) for child in descendants: if child in candidate_set: has_candidate_children = True break if has_candidate_children: # 这是一个容器元素,跳过 continue final_elements.append(element) return final_elements def extract_paragraphs_with_global_id(self, html_content: str, source_file: str = "") -> List[Dict]: """ 提取段落并分配全局唯一ID Args: html_content: HTML内容 source_file: 来源文件名(用于调试) Returns: 带全局ID的段落列表 """ try: soup = BeautifulSoup(html_content, 'html.parser') paragraphs = [] # 移除不需要的元素 for element in soup(['script', 'style', 'meta', 'link']): element.decompose() # 获取有效的文本元素 (使用统一的过滤逻辑) text_elements = self.get_valid_text_elements(soup) position = 0 for element in text_elements: # 清理文本:移除上标、下标等 clean_text = self._clean_element_text(element) # 过滤逻辑: # 1. 如果是导航元素,跳过 if self._is_navigation_element(element): continue # 2. 内容检查:只要不是空字符串,就保留 if not clean_text: continue # 分配全局唯一ID global_id = self._generate_global_id() paragraphs.append({ 'global_id': global_id, 'text': clean_text, 'html_element': str(element), 'source_file': source_file, 'position': position, # 在文件中的位置(重要!) 'tag': element.name, 'length': len(clean_text) }) position += 1 logger.info(f"从 {source_file} 提取了 {len(paragraphs)} 个段落") return paragraphs except Exception as e: logger.error(f"提取段落失败: {e}") return [] @staticmethod def clean_element_text(element) -> str: """ 清理元素文本:移除上标、下标、脚注等 (静态方法,供Builder共用) Args: element: HTML元素 Returns: 清理后的文本 """ # 复制元素,避免修改原始DOM element_copy = element.__copy__() # 移除上标和下标(通常是脚注引用) for tag in element_copy.find_all(['sup', 'sub']): tag.decompose() # 移除带有特定class的span/a标签 (脚注常见写法) footnote_patterns = re.compile(r'footnote|endnote|reference|note|super|sub', re.I) for tag in element_copy.find_all(['a', 'span', 'div'], class_=footnote_patterns): tag.decompose() # 移除仅包含数字或中括号数字的小型文本节点 (针对单纯文本形式的脚注 [1] 或 1) for tag in element_copy.find_all('span'): text = tag.get_text().strip() # 匹配 [1], (1), 1, 12 if re.match(r'^(\[\d+\]|\(\d+\)|\d+)$', text): tag.decompose() # 获取清理后的文本 text = element_copy.get_text().strip() # 额外的正则清理:移除正文末尾残留的引用标记,如 "text.[1]" 或 "text.1" text = re.sub(r'(\.|。|,|,)\s*(\[\d+\]|\d+)(?=\s|$)', r'\1', text) # 清理多余的空白 text = re.sub(r'\s+', ' ', text) return text @staticmethod def is_navigation_element(element) -> bool: """ 判断是否是导航元素 (静态方法,供Builder共用) Args: element: HTML元素 Returns: 是否是导航元素 """ # 检查class属性 classes = element.get('class', []) nav_classes = ['nav', 'navigation', 'toc', 'menu', 'header', 'footer', 'page-number'] # 处理 class 可能是列表或字符串的情况 if isinstance(classes, list): class_str = ' '.join(classes).lower() else: class_str = str(classes).lower() if any(nav_class in class_str for nav_class in nav_classes): return True # 检查父元素 parent = element.parent if parent: parent_classes = parent.get('class', []) if isinstance(parent_classes, list): parent_class_str = ' '.join(parent_classes).lower() else: parent_class_str = str(parent_classes).lower() if any(nav_class in parent_class_str for nav_class in nav_classes): return True return False def _clean_element_text(self, element) -> str: """兼容旧调用的包装器""" return self.clean_element_text(element) def _is_navigation_element(self, element) -> bool: """兼容旧调用的包装器""" return self.is_navigation_element(element) def _generate_global_id(self) -> str: """ 生成全局唯一ID Returns: 全局ID字符串,格式:p_0001 """ self._global_id_counter += 1 return f"p_{self._global_id_counter:04d}" def create_chunks_by_size(self, paragraphs: List[Dict]) -> List[List[Dict]]: """ 按字符数创建chunks,不切断段落,不考虑章节边界 Args: paragraphs: 带全局ID的段落列表 Returns: 分块的段落列表 """ if not paragraphs: return [] chunks = [] current_chunk = [] current_size = 0 for paragraph in paragraphs: para_length = paragraph['length'] # 如果当前chunk加上这个段落不超过限制,就加入 if current_size + para_length <= self.chunk_size: current_chunk.append(paragraph) current_size += para_length else: # 保存当前chunk(如果有内容) if current_chunk: chunks.append(current_chunk) # 开始新chunk current_chunk = [paragraph] current_size = para_length # 保存最后一个chunk if current_chunk: chunks.append(current_chunk) # 统计信息 total_chars = sum(p['length'] for p in paragraphs) avg_chunk_size = total_chars / len(chunks) if chunks else 0 logger.info(f"创建了 {len(chunks)} 个chunk," f"总段落数: {len(paragraphs)}, " f"平均chunk大小: {avg_chunk_size:.0f} 字符") # 显示chunk分布 for i, chunk in enumerate(chunks, 1): chunk_size = sum(p['length'] for p in chunk) logger.debug(f" Chunk {i}: {len(chunk)} 段落, {chunk_size} 字符, " f"ID范围: {chunk[0]['global_id']} - {chunk[-1]['global_id']}") return chunks def validate_translation(self, original: str, translation: str) -> bool: """ 验证翻译质量 Args: original: 原文 translation: 译文 Returns: 是否通过验证 """ # 检查是否是失败标记 if translation.startswith('[翻译失败') or translation.startswith('[解析失败'): return False # 检查基本长度 if len(translation) < len(original) * 0.1: logger.warning("翻译过短") return False if len(translation) > len(original) * 8: logger.warning("翻译过长") return False # 检查是否包含中文 if not re.search(r'[\u4e00-\u9fff]', translation): logger.warning("翻译不包含中文") return False return True def get_statistics(self, paragraphs: List[Dict]) -> Dict: """ 获取段落统计信息 Args: paragraphs: 段落列表 Returns: 统计信息字典 """ if not paragraphs: return {} total_chars = sum(p['length'] for p in paragraphs) avg_length = total_chars / len(paragraphs) # 按来源文件分组统计 by_source = {} for p in paragraphs: source = p['source_file'] if source not in by_source: by_source[source] = 0 by_source[source] += 1 return { 'total_paragraphs': len(paragraphs), 'total_characters': total_chars, 'average_length': round(avg_length, 1), 'min_length': min(p['length'] for p in paragraphs), 'max_length': max(p['length'] for p in paragraphs), 'by_source_file': by_source }