""" TOC 解析器模块 (TOC Parser Module) 该模块负责从 EPUB 文件中提取目录结构,并提供章节范围选择功能。 支持嵌套的目录结构(如 Part -> Chapter)。 """ from dataclasses import dataclass from typing import List, Set, Optional, Tuple from ebooklib import epub from loguru import logger from urllib.parse import urlparse @dataclass class TOCItem: """代表一个目录项""" index: int # 序号 (1-based) title: str # 章节标题 href: str # 文件路径 (如 index_split_003.html 或 e9781668053393/xhtml/ch01.xhtml) file_name: str # 纯文件名 (不含锚点) level: int # 层级 (0=顶级, 1=子章节, 2=子子章节) skip_reason: str = "" # 跳过原因: 'front', 'back', 或空字符串表示不跳过 # 前置部分 - 通常不需要翻译 FRONT_MATTER_PATTERNS = [ 'cover', 'title page', 'copyright', 'contents', 'table of contents', 'half title', 'halftitle', 'how to use this ebook', 'copyright page' ] # 后置部分 - 通常不需要翻译 # 注意:使用精确匹配避免误伤,如 "notes" 会匹配 "Technical Notes" BACK_MATTER_PATTERNS = [ 'endnotes', 'footnotes', 'bibliography', 'references', 'index', 'about the author', 'about the publisher', 'credits', 'appendix', 'glossary', 'also by', 'resources for' ] # 需要精确匹配的模式(标题必须完全等于这些值) BACK_MATTER_EXACT = [ 'notes' # 精确匹配,避免匹配 "Technical Notes" ] class TOCParser: """ TOC 解析器 从 EPUB 提取扁平化的目录列表,并支持章节范围选择。 """ def __init__(self, book: epub.EpubBook): self.book = book self._toc_items: List[TOCItem] = [] self._parse_toc() self._classify_all_chapters() def _parse_toc(self): """解析 book.toc,构建扁平化的目录列表""" self._toc_items = [] index = [0] # 使用列表以便在嵌套函数中修改 def traverse(toc_list, level=0): for item in toc_list: if isinstance(item, tuple): # 嵌套结构: (section, children) section, children = item index[0] += 1 href = section.href if hasattr(section, 'href') else "" file_name = self._extract_file_name(href) self._toc_items.append(TOCItem( index=index[0], title=section.title if hasattr(section, 'title') else str(section), href=href, file_name=file_name, level=level )) # 递归处理子节点 traverse(children, level + 1) else: # 叶子节点 index[0] += 1 href = item.href if hasattr(item, 'href') else "" file_name = self._extract_file_name(href) self._toc_items.append(TOCItem( index=index[0], title=item.title if hasattr(item, 'title') else str(item), href=href, file_name=file_name, level=level )) traverse(self.book.toc) logger.debug(f"解析 TOC 完成,共 {len(self._toc_items)} 个章节") def _classify_chapter(self, title: str) -> str: """ 分类单个章节 Returns: 'front': 前置部分(跳过) 'back': 后置部分(跳过) '': 正文内容(保留) """ title_lower = title.lower().strip() # 检查前置部分(模糊匹配) for pattern in FRONT_MATTER_PATTERNS: if pattern in title_lower or title_lower == pattern: return 'front' # 检查后置部分(模糊匹配) for pattern in BACK_MATTER_PATTERNS: if pattern in title_lower or title_lower == pattern: return 'back' # 检查后置部分(精确匹配) for pattern in BACK_MATTER_EXACT: if title_lower == pattern: return 'back' return '' def _classify_all_chapters(self): """对所有章节进行分类""" for item in self._toc_items: item.skip_reason = self._classify_chapter(item.title) # 统计跳过数量 front_count = sum(1 for i in self._toc_items if i.skip_reason == 'front') back_count = sum(1 for i in self._toc_items if i.skip_reason == 'back') if front_count or back_count: logger.debug(f"章节分类: 跳过前置 {front_count} 个,跳过后置 {back_count} 个") def get_skip_files(self) -> Set[str]: """获取应该跳过的文件集合""" return {item.file_name for item in self._toc_items if item.skip_reason and item.file_name} def get_content_files(self) -> Set[str]: """获取正文内容的文件集合(排除前置和后置)""" return {item.file_name for item in self._toc_items if not item.skip_reason and item.file_name} def get_spine_files(self) -> List[str]: """获取 Spine 中的所有文件(按阅读顺序)""" spine_files = [] for item_tuple in self.book.spine: item_id = item_tuple[0] item = self.book.get_item_with_id(item_id) if item: spine_files.append(item.get_name()) return spine_files def get_content_files_from_spine(self) -> Set[str]: """ 基于 Spine 获取正文内容文件(排除前置和后置) 核心逻辑: 1. 找到第一个正文章节在 Spine 中的位置 2. 找到最后一个正文章节在 Spine 中的位置 3. 返回这个范围内的所有 Spine 文件 """ spine_files = self.get_spine_files() if not spine_files: return self.get_content_files() # 降级到 TOC 文件 # 获取正文和跳过的 TOC 文件 content_toc_files = self.get_content_files() skip_toc_files = self.get_skip_files() if not content_toc_files: return set(spine_files) # 没有分类信息,返回所有 # 在 Spine 中找到正文内容的边界 first_content_idx = None last_content_idx = None for idx, spine_file in enumerate(spine_files): if spine_file in content_toc_files: if first_content_idx is None: first_content_idx = idx last_content_idx = idx if first_content_idx is None: return self.get_content_files() # 降级 # 收集边界内的所有 Spine 文件 result = set() for idx in range(first_content_idx, last_content_idx + 1): spine_file = spine_files[idx] # 排除明确标记为跳过的文件 if spine_file not in skip_toc_files: result.add(spine_file) logger.debug(f"Spine 正文范围: {first_content_idx+1} ~ {last_content_idx+1},共 {len(result)} 个文件") return result def get_spine_range(self, start_title: str = None, end_title: str = None) -> Tuple[Set[str], List[TOCItem]]: """ 基于 Spine 和 TOC 边界获取文件范围 与 get_file_range 的区别: - get_file_range: 只返回 TOC 中列出的文件 - get_spine_range: 返回 TOC 边界之间的所有 Spine 文件 """ spine_files = self.get_spine_files() # 确定 TOC 边界 start_item = self.find_by_title(start_title) if start_title else None end_item = self.find_by_title(end_title) if end_title else None start_idx = start_item.index if start_item else 1 end_idx = end_item.index if end_item else len(self._toc_items) if start_idx > end_idx: start_idx, end_idx = end_idx, start_idx # 获取选中的 TOC 项 selected_items = [i for i in self._toc_items if start_idx <= i.index <= end_idx] selected_toc_files = {i.file_name for i in selected_items if i.file_name} # 在 Spine 中找到这些文件的边界 spine_start = None spine_end = None for idx, spine_file in enumerate(spine_files): if spine_file in selected_toc_files: if spine_start is None: spine_start = idx spine_end = idx if spine_start is None: # 降级到 TOC 文件 logger.warning("无法在 Spine 中定位章节边界,使用 TOC 文件") return selected_toc_files, selected_items # 扩展到下一个 TOC 章节之前 # 找到 end_idx 之后的下一个 TOC 章节在 Spine 中的位置 next_toc_file = None if end_idx < len(self._toc_items): next_toc_file = self._toc_items[end_idx].file_name # end_idx 是 1-based if next_toc_file: for idx, spine_file in enumerate(spine_files): if spine_file == next_toc_file: spine_end = idx - 1 # 到下一章之前 break # 收集 Spine 范围内的所有文件 result = set() for idx in range(spine_start, spine_end + 1): if idx < len(spine_files): result.add(spine_files[idx]) logger.info(f"Spine 范围: #{spine_start+1} ~ #{spine_end+1},共 {len(result)} 个文件(TOC: {len(selected_toc_files)} 个)") return result, selected_items def _extract_file_name(self, href: str) -> str: """从 href 中提取纯文件名(去除锚点和路径前缀)""" if not href: return "" # 去除锚点 (#section1) path = href.split('#')[0] # 返回完整路径(可能包含子目录) return path @property def items(self) -> List[TOCItem]: """获取所有目录项""" return self._toc_items def find_by_title(self, title: str, fuzzy: bool = True) -> Optional[TOCItem]: """ 根据标题查找目录项 Args: title: 章节标题 fuzzy: 是否模糊匹配(包含即可) Returns: 匹配的 TOCItem 或 None """ title_lower = title.lower().strip() for item in self._toc_items: item_title_lower = item.title.lower().strip() if fuzzy: # 模糊匹配:互相包含 if title_lower in item_title_lower or item_title_lower in title_lower: return item else: # 精确匹配 if item_title_lower == title_lower: return item return None def find_by_index(self, index: int) -> Optional[TOCItem]: """根据序号查找目录项 (1-based)""" if 1 <= index <= len(self._toc_items): return self._toc_items[index - 1] return None def get_file_range(self, start_title: str = None, end_title: str = None, start_index: int = None, end_index: int = None) -> Tuple[Set[str], List[TOCItem]]: """ 获取指定范围内的文件集合 支持两种方式指定范围: 1. 按标题: start_title ~ end_title 2. 按序号: start_index ~ end_index Returns: (文件名集合, 选中的目录项列表) """ # 确定起始位置 start_item = None if start_title: start_item = self.find_by_title(start_title) if not start_item: logger.warning(f"未找到起始章节: {start_title}") elif start_index: start_item = self.find_by_index(start_index) # 确定结束位置 end_item = None if end_title: end_item = self.find_by_title(end_title) if not end_item: logger.warning(f"未找到结束章节: {end_title}") elif end_index: end_item = self.find_by_index(end_index) # 默认值 start_idx = start_item.index if start_item else 1 end_idx = end_item.index if end_item else len(self._toc_items) # 确保顺序正确 if start_idx > end_idx: start_idx, end_idx = end_idx, start_idx # 收集文件 selected_items = [] file_names = set() for item in self._toc_items: if start_idx <= item.index <= end_idx: selected_items.append(item) if item.file_name: file_names.add(item.file_name) logger.info(f"选择范围: #{start_idx} ~ #{end_idx},共 {len(file_names)} 个文件") return file_names, selected_items def format_toc_table(self, selected_range: Tuple[int, int] = None, show_skip: bool = True) -> str: """ 格式化 TOC 为表格形式,用于终端显示 Args: selected_range: 可选的选中范围 (start_index, end_index),用于高亮显示 show_skip: 是否显示跳过标记 Returns: 格式化的表格字符串 """ if not self._toc_items: return "目录为空" lines = [] lines.append("") lines.append("=" * 75) lines.append(f"{'#':>4} {'状态':<6} {'章节名称':<35} {'文件'}") lines.append("=" * 75) for item in self._toc_items: indent = " " * item.level title_display = f"{indent}{item.title}" if len(title_display) > 33: title_display = title_display[:30] + "..." # 跳过状态标记 status = "" if show_skip and item.skip_reason: status = "[SKIP]" if item.skip_reason else "" # 如果在选中范围内,添加标记 marker = "" if selected_range: start_idx, end_idx = selected_range if item.index == start_idx: marker = " ▶" elif item.index == end_idx: marker = " ◀" elif start_idx < item.index < end_idx: marker = " │" lines.append(f"{item.index:>4}{marker:2} {status:<6} {title_display:<35} {item.file_name}") lines.append("=" * 75) # 统计摘要 skip_count = sum(1 for i in self._toc_items if i.skip_reason) content_count = len(self._toc_items) - skip_count lines.append(f" 正文章节: {content_count} | 跳过章节: {skip_count}") lines.append("") return "\n".join(lines)