feat: Release v0.10 - Modular Architecture & External Config

- Refactor codebase into src/ (preprocessing, translation, assembly)
- Add pipeline/ scripts for individual stages
- Externalize configuration to config/config.yaml
- Fix Cover Image preservation
- Update documentation and manuals
This commit is contained in:
谭凯
2026-01-31 22:49:44 +08:00
parent 9ef82393be
commit 7a93c52b42
306 changed files with 30313 additions and 1071 deletions
+411
View File
@@ -0,0 +1,411 @@
"""
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)