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:
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
EPUB 清理器模块 (EpubCleaner)
|
||||
|
||||
负责在翻译前对 EPUB 进行标准化清洗,解决兼容性问题。
|
||||
核心功能:
|
||||
1. Flatten Structure: 将 div 转换为 p,简化结构
|
||||
2. Fix TOC: 修复目录中的死链和缺失 UID
|
||||
3. CSS Restoration: 找回丢失的样式表
|
||||
"""
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from loguru import logger
|
||||
from ebooklib import epub
|
||||
import ebooklib
|
||||
import zipfile
|
||||
import uuid
|
||||
from ebooklib.epub import Link
|
||||
|
||||
class EpubCleaner:
|
||||
"""标准 EPUB 清理器"""
|
||||
|
||||
def clean_epub(self, input_path: str, output_path: str):
|
||||
"""
|
||||
清理 EPUB 文件并保存到新路径
|
||||
"""
|
||||
logger.info(f"开始清理: {input_path}")
|
||||
|
||||
# 1. 尝试打开 Zip 以读取原始内容 (Ebooklib 回退机制)
|
||||
try:
|
||||
input_zip = zipfile.ZipFile(input_path, 'r')
|
||||
zip_files = set(input_zip.namelist())
|
||||
except Exception as e:
|
||||
logger.error(f"无法打开 Zip (样式回退功能将失效): {e}")
|
||||
input_zip = None
|
||||
zip_files = set()
|
||||
|
||||
# 2. 读取 EPUB
|
||||
try:
|
||||
book = epub.read_epub(input_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Ebooklib 读取失败: {e}")
|
||||
raise
|
||||
|
||||
# 3. 遍历并清理文档
|
||||
count = 0
|
||||
for item in book.get_items():
|
||||
if item.get_type() == ebooklib.ITEM_DOCUMENT:
|
||||
try:
|
||||
file_name = item.get_name()
|
||||
content = None
|
||||
|
||||
# 优先从 Zip 读取以保留 Head 信息 (CSS Links)
|
||||
if input_zip and file_name in zip_files:
|
||||
try:
|
||||
content = input_zip.read(file_name).decode('utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退到 ebooklib
|
||||
if content is None:
|
||||
raw_content = item.get_content()
|
||||
if raw_content:
|
||||
content = raw_content.decode('utf-8')
|
||||
|
||||
if not content or not content.strip():
|
||||
continue
|
||||
|
||||
# 执行清理
|
||||
cleaned = self._clean_content(content, item)
|
||||
|
||||
# 安全检查
|
||||
if not cleaned.strip():
|
||||
logger.warning(f"警告: {file_name} 清理后为空,保留原始内容")
|
||||
cleaned = content
|
||||
|
||||
item.set_content(cleaned.encode('utf-8'))
|
||||
count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"清理文档失败 {item.get_name()}: {e}")
|
||||
|
||||
# 4. 修复 TOC (死链和 UID)
|
||||
try:
|
||||
book.toc = self._fix_and_clean_toc(book.toc, book)
|
||||
except Exception as e:
|
||||
logger.error(f"TOC 修复失败: {e}")
|
||||
|
||||
# 5. 保存
|
||||
epub.write_epub(output_path, book)
|
||||
logger.info(f"清理完成: {output_path} (处理了 {count} 个文档)")
|
||||
|
||||
def _clean_content(self, html_content: str, item=None) -> str:
|
||||
"""
|
||||
执行具体的 HTML 清理逻辑
|
||||
"""
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
|
||||
# 恢复 CSS 链接
|
||||
if item:
|
||||
self._restore_css_links(soup, item)
|
||||
|
||||
# div 转 p
|
||||
stats = {'divs_to_p': 0}
|
||||
inline_tags = {'span', 'em', 'strong', 'i', 'b', 'u', 'a', 'br', 'sub', 'sup'}
|
||||
|
||||
for div in list(soup.find_all('div')):
|
||||
# 检查是否有块级子元素 (如果有,则保留 div 容器结构)
|
||||
has_block = any(
|
||||
isinstance(c, Tag) and c.name not in inline_tags
|
||||
for c in div.children
|
||||
)
|
||||
|
||||
if not has_block:
|
||||
div.name = 'p'
|
||||
stats['divs_to_p'] += 1
|
||||
|
||||
# 可以在这里添加更多清理逻辑 (如移除无用的空的 span 等)
|
||||
|
||||
return str(soup)
|
||||
|
||||
def _restore_css_links(self, soup, item):
|
||||
"""从原始 HTML 中提取并恢复 CSS 链接到 item 对象"""
|
||||
head = soup.find('head')
|
||||
if head:
|
||||
links = head.find_all('link', rel='stylesheet')
|
||||
for link in links:
|
||||
href = link.get('href')
|
||||
if href:
|
||||
existing_links = list(item.get_links())
|
||||
exists = False
|
||||
for l in existing_links:
|
||||
l_href = getattr(l, 'href', None)
|
||||
if l_href is None and isinstance(l, dict):
|
||||
l_href = l.get('href')
|
||||
if l_href == href:
|
||||
exists = True
|
||||
break
|
||||
|
||||
if not exists:
|
||||
item.add_link(href=href, rel='stylesheet', type='text/css')
|
||||
|
||||
def _fix_and_clean_toc(self, toc, book):
|
||||
"""修复 TOC:补全 UID 并移除指向不存在文件的死链"""
|
||||
new_toc = []
|
||||
|
||||
for item in toc:
|
||||
# Case 1: (Section, Children)
|
||||
if isinstance(item, (tuple, list)):
|
||||
section, children = item
|
||||
cleaned_children = self._fix_and_clean_toc(children, book)
|
||||
|
||||
if isinstance(section, Link):
|
||||
href = section.href.split('#')[0]
|
||||
if book.get_item_with_href(href):
|
||||
if section.uid is None:
|
||||
section.uid = f'uuid-{uuid.uuid4()}'
|
||||
new_toc.append((section, cleaned_children))
|
||||
else:
|
||||
logger.warning(f"移除无效 TOC 节点: {section.href}")
|
||||
new_toc.extend(cleaned_children)
|
||||
else:
|
||||
new_toc.append((section, cleaned_children))
|
||||
|
||||
# Case 2: Link
|
||||
elif isinstance(item, Link):
|
||||
href = item.href.split('#')[0]
|
||||
if book.get_item_with_href(href):
|
||||
if item.uid is None:
|
||||
item.uid = f'uuid-{uuid.uuid4()}'
|
||||
new_toc.append(item)
|
||||
else:
|
||||
logger.warning(f"移除无效 TOC 节点: {item.href}")
|
||||
|
||||
# Case 3: Other
|
||||
else:
|
||||
new_toc.append(item)
|
||||
|
||||
return new_toc
|
||||
Reference in New Issue
Block a user