Initial commit

This commit is contained in:
谭凯
2026-01-19 09:51:07 +08:00
commit 9ef82393be
174 changed files with 22285 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
"""
EPUB 双语翻译程序
主要功能模块的初始化文件
"""
__version__ = "0.1.0"
__author__ = "Kaitan"
from .epub_parser import EPUBParser
from .translator import EPUBTranslator
from .llm_client import OpenRouterClient
from .text_processor import TextProcessor
from .bilingual_builder import BilingualEPUBBuilder
from .utils import load_config, setup_logging
__all__ = [
"EPUBParser",
"EPUBTranslator",
"OpenRouterClient",
"TextProcessor",
"BilingualEPUBBuilder",
"load_config",
"setup_logging"
]
+411
View File
@@ -0,0 +1,411 @@
"""
双语 EPUB 构建器模块 - 安全的EPUB构建
不使用deepcopy,而是创建新书并复制必要内容
"""
from ebooklib import epub
import ebooklib
from bs4 import BeautifulSoup
from typing import Dict
from pathlib import Path
from loguru import logger
import uuid
class BilingualEPUBBuilder:
"""双语 EPUB 构建器 - 安全版本"""
def __init__(self, original_book, config: Dict):
"""初始化构建器"""
self.original_book = original_book
self.config = config
self.output_config = config['output']
def create_bilingual_epub_with_mapping(self, translation_map: Dict[str, str],
paragraph_map: Dict[str, Dict],
output_path: str) -> str:
"""
创建双语 EPUB(使用段落映射)
重建策略:
1. 复制所有非文档资源(图片、CSS等)
2. 遍历原书 Spine,逐个处理:
- 如果是需要翻译的文档 -> 生成双语版本 -> 添加
- 如果是不需要翻译的文档(封面、版权页)-> 直接复制 -> 添加
3. 确保所有元数据和封面被保留
"""
try:
# 创建新书
new_book = epub.EpubBook()
# 1. 全面复制元数据(包括封面设置)
self._copy_metadata(new_book)
# 复制目录结构 (TOC)
# 这一步至关重要,否则生成的 NCX/Nav 将是空的
# 由于我们保留了原始文件名,原有的 href 链接仍然有效
new_book.toc = self.original_book.toc
# 准备每个文件的有序ID列表
file_ordered_ids = {}
sorted_pids = sorted(paragraph_map.keys(), key=lambda x: int(x.split('_')[1]))
for pid in sorted_pids:
info = paragraph_map[pid]
fname = info['file_name']
if fname not in file_ordered_ids:
file_ordered_ids[fname] = []
file_ordered_ids[fname].append(pid)
# 记录已处理的 Item ID,防止重复
processed_item_ids = set()
# 记录新旧 Item ID 的映射 (old_id -> new_item)
item_map = {}
# 2. 复制所有非文档资源 (Images, CSS, Fonts, etc.)
# 注意:不包括 NCX/Nav,它们会在最后自动生成或需要特殊处理
for item in self.original_book.get_items():
if item.get_type() != ebooklib.ITEM_DOCUMENT:
# 对于非文档,直接添加到新书
# 注意:Image Item 如果是封面,在 copy_metadata 里可能已经处理过,这里需要小心重复
# ebooklib 的 add_item 会处理 id 冲突吗?最好检查一下
if item.id not in processed_item_ids:
new_book.add_item(item)
processed_item_ids.add(item.id)
item_map[item.id] = item
logger.debug(f"复制资源: {item.get_name()} ({item.get_type()})")
# 3. 重建 Spine (核心逻辑:保持原书阅读顺序)
# 移除 'nav',不要强制将其作为第一页
new_spine = []
for spine_id, linear in self.original_book.spine:
item = self.original_book.get_item_with_id(spine_id)
if not item:
continue
# 如果是文档类型 (HTML)
if item.get_type() == ebooklib.ITEM_DOCUMENT:
file_name = item.get_name()
# 判断是否需要翻译
if file_name in file_ordered_ids:
# 创建双语版本
new_item = self._create_bilingual_document(
item,
file_ordered_ids[file_name],
translation_map
)
# 保持原 ID,这对 TOC 链接很重要
new_item.id = item.id
else:
# 不需要翻译(如封面、版权页),直接使用原 Item
logger.info(f"保留原文(未翻译): {file_name}")
new_item = item
# 添加到新书
if new_item.id not in processed_item_ids:
new_book.add_item(new_item)
processed_item_ids.add(new_item.id)
item_map[new_item.id] = new_item
# 添加到 Spine
new_spine.append(new_item) # ebooklib spine 接受 item 对象
else:
# 非文档类型在 Spine 中 (比较少见,可能是图片页)
if item.id in item_map:
new_spine.append(item_map[item.id])
# 设置新书 Spine
new_book.spine = new_spine
# 4. 处理未在 Spine 中的文档 (Orphaned Documents)
# 有些 EPUB 会有未列在 spine 中的 HTML (如弹窗注释)
for item in self.original_book.get_items():
if item.get_type() == ebooklib.ITEM_DOCUMENT and item.id not in processed_item_ids:
# 同样检查是否翻译
file_name = item.get_name()
if file_name in file_ordered_ids:
new_item = self._create_bilingual_document(
item,
file_ordered_ids[file_name],
translation_map
)
new_item.id = item.id
else:
new_item = item
new_book.add_item(new_item)
processed_item_ids.add(new_item.id)
logger.debug(f"添加非Spine文档: {file_name}")
# 5. 添加双语样式
self._add_bilingual_style(new_book)
# 6. 添加导航文件
new_book.add_item(epub.EpubNcx())
new_book.add_item(epub.EpubNav())
# 生成输出文件
output_file = self._generate_output_filename(output_path)
epub.write_epub(output_file, new_book, {})
logger.info(f"双语 EPUB 创建成功: {output_file} (Spine 包含 {len(new_spine)} 项)")
return output_file
except Exception as e:
logger.error(f"创建双语 EPUB 失败: {e}", exc_info=True)
raise
def _copy_metadata(self, new_book):
"""全面复制元数据"""
try:
# 1. 复制所有 DC 元数据 (Title, Creator, Language, etc.)
for namespace, meta_dict in self.original_book.metadata.items():
for name, values in meta_dict.items():
for value, other in values:
try:
# 过滤掉 Identifier,我们稍后会生成新的
if name.lower() == 'identifier':
continue
new_book.add_metadata(namespace, name, value, other)
except Exception as e:
logger.warning(f"复制元数据失败 {namespace}:{name}: {e}")
# 2. 显式设置关键元数据,确保不为空
# 标题
title = new_book.get_metadata('DC', 'title')
if not title:
new_book.set_title("Bilingual Book")
else:
# 修改标题以标示双语
new_title = f"{title[0][0]} (双语版)"
# 清除旧标题,添加新标题 (ebooklib 的 set_title 实际上是 append,这里简化处理)
# 为简单起见,我们再添加一个 Title 记录
new_book.add_metadata('DC', 'title', new_title)
# 语言 (强制设为中文,或保留原样并添加中文)
new_book.add_metadata('DC', 'language', 'zh-CN')
# 3. 设置唯一 ID
unique_id = f"bilingual-{uuid.uuid4().hex[:12]}"
new_book.set_identifier(unique_id)
# 4. 处理封面 (Cover)
# 尝试从 OPF metadata 中找到 cover item id
cover_id_meta = self.original_book.get_metadata('OPF', 'cover')
if cover_id_meta:
cover_id = cover_id_meta[0][0]
cover_item = self.original_book.get_item_with_id(cover_id)
if cover_item:
# 复制封面图片 item
new_book.add_item(cover_item)
new_book.set_cover(cover_item.get_name(), cover_item.get_content())
logger.info(f"成功复制封面: {cover_item.get_name()}")
logger.info("元数据复制完成")
except Exception as e:
logger.error(f"元数据复制过程中出错: {e}")
# 保底措施
new_book.set_title("Bilingual Book")
new_book.set_language("en")
new_book.set_identifier(f"bilingual-fallback-{uuid.uuid4().hex[:8]}")
def _create_bilingual_document(self, original_item, ordered_ids: list, translation_map: dict):
"""
创建双语文档 - 基于全局ID的精确对齐
Args:
original_item: 原始EPUB文档项
ordered_ids: 该文件对应的有序全局ID列表 [p_0100, p_0101, ...]
translation_map: 全局翻译映射
Returns:
新的双语文档项
"""
try:
from .text_processor import TextProcessor
# 读取原始HTML
original_html = original_item.get_content().decode('utf-8')
soup = BeautifulSoup(original_html, 'html.parser')
# 添加样式链接
self._add_style_link(soup)
# 获取此文件预期的段落数量
expected_count = len(ordered_ids)
# 2. 遍历并匹配 DOM 元素
# 使用与 TextProcessor 完全相同的选择器和过滤逻辑
text_elements = TextProcessor.get_valid_text_elements(soup)
matched_count = 0
current_para_index = 0
for element in text_elements:
# 2.1 过滤逻辑 (必须与 TextProcessor 严格一致)
# 检查是否是导航元素 (使用 TextProcessor 的逻辑)
if TextProcessor.is_navigation_element(element):
continue
# 获取清理后的文本用于长度检查 (使用 TextProcessor 的逻辑)
clean_text = TextProcessor.clean_element_text(element)
# 只要非空,就是有效段落 (无最小长度限制)
if not clean_text:
continue
# 2.2 匹配 ID
# 此时,我们找到了一个 "有效段落",它对应于该文件 ID 序列中的下一个 ID
if current_para_index < expected_count:
target_id = ordered_ids[current_para_index]
# 查找是否有翻译
translation = translation_map.get(target_id)
# 2.3 插入翻译 (如果有)
if translation and not translation.startswith('[翻译失败') and not translation.startswith('[解析失败'):
self._insert_translation(element, translation, soup)
matched_count += 1
logger.debug(f"ID匹配: {target_id} -> {clean_text[:20]}...")
else:
# 即使没有翻译,也要推进索引,确保后续 ID 对齐
logger.debug(f"ID跳过(无翻译): {target_id}")
current_para_index += 1
else:
# 如果找到了比预期更多的段落,说明 filtering 逻辑有偏差,或者文件发生了变化
logger.warning(f"发现多余段落 (索引 {current_para_index}): {clean_text[:20]}...")
if matched_count > 0:
# 创建新的EpubHtml项
new_item = epub.EpubHtml(
title=original_item.title or "Chapter",
file_name=original_item.get_name(),
lang='zh-CN'
)
new_item.set_content(str(soup).encode('utf-8'))
logger.info(f"创建双语文档 {original_item.get_name()}: 成功插入 {matched_count} 个翻译 (共 {expected_count} 段)")
return new_item
else:
logger.warning(f"文档 {original_item.get_name()} 没有插入任何翻译 (共 {expected_count} 段)")
return original_item
except Exception as e:
logger.error(f"创建双语文档失败 {original_item.get_name()}: {e}", exc_info=True)
return original_item
def _add_style_link(self, soup):
"""添加样式链接"""
head = soup.find('head')
if head:
existing_links = head.find_all('link', {'rel': 'stylesheet'})
has_bilingual = any('bilingual.css' in link.get('href', '') for link in existing_links)
if not has_bilingual:
style_link = soup.new_tag('link', rel='stylesheet',
type='text/css', href='style/bilingual.css')
head.append(style_link)
def _insert_translation(self, element, translation: str, soup):
"""在元素后插入翻译段落"""
try:
# 为原元素添加样式类
classes = element.get('class', [])
if not isinstance(classes, list):
classes = [str(classes)] if classes else []
classes.extend(['original-text', 'english'])
element['class'] = classes
# 创建翻译段落
translation_p = soup.new_tag('p')
translation_p.string = translation
translation_p['class'] = ['translation-text', 'chinese']
# 插入到原元素后
element.insert_after(translation_p)
except Exception as e:
logger.warning(f"插入翻译失败: {e}")
def _add_bilingual_style(self, new_book):
"""添加双语样式"""
try:
# 检查是否已存在
for item in new_book.get_items():
if (item.get_type() == ebooklib.ITEM_STYLE and
'bilingual.css' in item.get_name()):
logger.debug("双语样式已存在")
return
# 添加样式
css_content = """
.original-text {
font-family: "Times New Roman", serif;
line-height: 1.5;
margin-bottom: 8px;
color: #333;
}
.translation-text {
font-family: "SimSun", "Microsoft YaHei", sans-serif;
line-height: 1.7;
margin-bottom: 16px;
color: #555;
background-color: #f9f9f9;
padding: 8px;
border-left: 3px solid #ddd;
border-radius: 3px;
}
@media screen and (max-width: 600px) {
.original-text { font-size: 14px; }
.translation-text { font-size: 13px; padding: 6px; }
}
"""
css_item = epub.EpubItem(
uid="bilingual_style",
file_name="style/bilingual.css",
media_type="text/css",
content=css_content
)
new_book.add_item(css_item)
logger.debug("添加双语样式完成")
except Exception as e:
logger.warning(f"添加样式失败: {e}")
def _generate_output_filename(self, output_path: str) -> str:
"""生成输出文件名"""
try:
output_dir = Path(output_path)
# 获取原始标题
original_title = "unknown"
try:
title_items = self.original_book.get_metadata('DC', 'title')
if title_items:
original_title = title_items[0][0]
except:
pass
# 清理文件名
from .utils import sanitize_filename
clean_title = sanitize_filename(original_title)
# 添加后缀
suffix = self.output_config.get('filename_suffix', '_bilingual')
filename = f"{clean_title}{suffix}.epub"
# 确保输出目录存在
output_dir.mkdir(parents=True, exist_ok=True)
return str(output_dir / filename)
except Exception as e:
logger.warning(f"生成文件名失败: {e}")
return str(Path(output_path) / "bilingual_book.epub")
+225
View File
@@ -0,0 +1,225 @@
"""
翻译缓存管理模块 - 简化版
基于全局ID和chunk的缓存系统
"""
import json
import hashlib
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, Optional, List
from loguru import logger
class TranslationCache:
"""翻译缓存管理器 - 简化版"""
def __init__(self, config: Dict):
"""初始化缓存管理器"""
self.config = config
cache_config = config.get('cache', {})
self.enabled = cache_config.get('enabled', True)
self.cache_dir = Path(cache_config.get('directory', 'cache'))
self.max_age_days = cache_config.get('max_age_days', 30)
if self.enabled:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.translations_dir = self.cache_dir / 'translations'
self.translations_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"翻译缓存已启用: {self.cache_dir}")
def get_chunk_translation(self, chunk: List[Dict], model: str) -> Optional[Dict[str, str]]:
"""
获取chunk的缓存翻译
Args:
chunk: 段落列表(带global_id)
model: 模型名称
Returns:
{global_id: translation} 映射,如果不存在返回 None
"""
if not self.enabled:
return None
try:
cache_key = self._get_chunk_cache_key(chunk, model)
cache_file = self._get_cache_file_path(cache_key)
if not cache_file.exists():
return None
# 检查是否过期
file_age = datetime.now() - datetime.fromtimestamp(cache_file.stat().st_mtime)
if file_age > timedelta(days=self.max_age_days):
logger.debug(f"缓存已过期: {cache_key[:8]}...")
cache_file.unlink()
return None
# 读取缓存
with open(cache_file, 'r', encoding='utf-8') as f:
cache_data = json.load(f)
# 验证缓存
if (cache_data.get('success') and
cache_data.get('model') == model and
self._validate_cache_data(cache_data, chunk)):
logger.debug(f"缓存命中: {cache_key[:8]}... ({len(chunk)} 段落)")
return cache_data.get('translations', {})
return None
except Exception as e:
logger.warning(f"读取缓存失败: {e}")
return None
def save_chunk_translation(self, chunk: List[Dict], translations: Dict[str, str],
model: str, success: bool = True) -> None:
"""
保存chunk翻译到缓存
Args:
chunk: 段落列表(带global_id)
translations: {global_id: translation} 映射
model: 模型名称
success: 是否翻译成功
"""
if not self.enabled:
return
try:
cache_key = self._get_chunk_cache_key(chunk, model)
cache_file = self._get_cache_file_path(cache_key)
# 构建缓存数据
cache_data = {
'global_ids': [p['global_id'] for p in chunk],
'translations': translations,
'model': model,
'timestamp': datetime.now().isoformat(),
'success': success,
'paragraph_count': len(chunk),
'cache_version': '3.0'
}
with open(cache_file, 'w', encoding='utf-8') as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
logger.debug(f"缓存已保存: {cache_key[:8]}... ({len(chunk)} 段落)")
except Exception as e:
logger.warning(f"保存缓存失败: {e}")
def _get_chunk_cache_key(self, chunk: List[Dict], model: str) -> str:
"""
生成chunk缓存键(基于全局ID序列)
Args:
chunk: 段落列表
model: 模型名称
Returns:
缓存键
"""
# 使用全局ID序列作为缓存键的一部分
id_sequence = ",".join(p['global_id'] for p in chunk)
combined = f"{id_sequence}|{model}"
return hashlib.md5(combined.encode('utf-8')).hexdigest()
def _get_cache_file_path(self, cache_key: str) -> Path:
"""获取缓存文件路径"""
today = datetime.now().strftime('%Y-%m-%d')
cache_date_dir = self.translations_dir / today
cache_date_dir.mkdir(parents=True, exist_ok=True)
return cache_date_dir / f"{cache_key}.json"
def _validate_cache_data(self, cache_data: Dict, chunk: List[Dict]) -> bool:
"""验证缓存数据的有效性"""
# 检查ID序列是否匹配
cached_ids = cache_data.get('global_ids', [])
chunk_ids = [p['global_id'] for p in chunk]
if cached_ids != chunk_ids:
logger.debug("缓存ID序列不匹配")
return False
# 检查翻译数量
translations = cache_data.get('translations', {})
if len(translations) != len(chunk):
logger.debug("缓存翻译数量不匹配")
return False
return True
def clear_cache(self, older_than_days: Optional[int] = None) -> int:
"""清理缓存"""
if not self.enabled or not self.translations_dir.exists():
return 0
cleared_count = 0
cutoff_time = None
if older_than_days is not None:
cutoff_time = datetime.now() - timedelta(days=older_than_days)
try:
for cache_file in self.translations_dir.rglob('*.json'):
should_delete = False
if cutoff_time is None:
should_delete = True
else:
file_time = datetime.fromtimestamp(cache_file.stat().st_mtime)
should_delete = file_time < cutoff_time
if should_delete:
cache_file.unlink()
cleared_count += 1
# 清理空目录
for date_dir in self.translations_dir.iterdir():
if date_dir.is_dir() and not any(date_dir.iterdir()):
date_dir.rmdir()
logger.info(f"清理了 {cleared_count} 个缓存文件")
return cleared_count
except Exception as e:
logger.error(f"清理缓存失败: {e}")
return 0
def get_cache_stats(self) -> Dict:
"""获取缓存统计信息"""
if not self.enabled or not self.translations_dir.exists():
return {'enabled': False}
try:
cache_files = list(self.translations_dir.rglob('*.json'))
total_files = len(cache_files)
total_size = sum(f.stat().st_size for f in cache_files)
# 统计段落数
total_paragraphs = 0
for cache_file in cache_files:
try:
with open(cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
total_paragraphs += data.get('paragraph_count', 0)
except:
continue
return {
'enabled': True,
'total_files': total_files,
'total_paragraphs': total_paragraphs,
'total_size_mb': round(total_size / 1024 / 1024, 2),
'cache_directory': str(self.cache_dir),
'max_age_days': self.max_age_days
}
except Exception as e:
logger.error(f"获取缓存统计失败: {e}")
return {'enabled': True, 'error': str(e)}
+164
View File
@@ -0,0 +1,164 @@
"""
EPUB 解析器模块 (EPUB Parser Module)
该模块负责读取 EPUB 文件,提取元数据和内容项目。
它使用 ebooklib 库来处理 EPUB 格式的底层细节。
Classes:
EPUBParser: 负责 EPUB 文件的加载、元数据提取和内容项遍历。
"""
import ebooklib
from ebooklib import epub
from bs4 import BeautifulSoup
from typing import List, Dict, Any
from pathlib import Path
from loguru import logger
class EPUBParser:
"""
EPUB 文件解析器。
负责加载 EPUB 文件,提取书籍元数据(如标题、作者),并提供方法来遍历和提取
书中的文档内容(HTML/XHTML)。
Attributes:
epub_path (Path): EPUB 文件的路径对象。
book (epub.EpubBook): ebooklib 加载的书籍对象。
metadata (Dict[str, str]): 提取的书籍元数据字典。
"""
def __init__(self, epub_path: str):
"""
初始化 EPUB 解析器。
Args:
epub_path (str): EPUB 文件的文件路径。
Raises:
FileNotFoundError: 如果指定的文件不存在。
Exception: 如果 EPUB 文件加载失败(格式错误等)。
"""
self.epub_path = Path(epub_path)
if not self.epub_path.exists():
raise FileNotFoundError(f"EPUB 文件不存在: {epub_path}")
try:
# ignore_ncx=True 是为了避免某些旧版 epub 的警告,但新版 ebooklib 可能行为不同
# 这里直接读取,让 ebooklib 处理
self.book = epub.read_epub(str(self.epub_path))
logger.info(f"成功加载 EPUB: {self.epub_path.name}")
except Exception as e:
logger.error(f"加载 EPUB 失败: {e}")
raise
self.metadata = self._extract_metadata()
def _extract_metadata(self) -> Dict[str, str]:
"""
从 EPUB 对象中提取标准元数据。
提取 Dublin Core (DC) 元数据,包括标题、作者和语言。
Returns:
Dict[str, str]: 包含 'title', 'author', 'language' 的字典。
如果提取失败,会使用默认值 ("Unknown", "en")。
"""
metadata = {}
try:
# get_metadata 返回的是 (value, dict) 的列表,我们取第一个结果
title_meta = self.book.get_metadata('DC', 'title')
metadata['title'] = title_meta[0][0] if title_meta else "Unknown"
author_meta = self.book.get_metadata('DC', 'creator')
metadata['author'] = author_meta[0][0] if author_meta else "Unknown"
lang_meta = self.book.get_metadata('DC', 'language')
metadata['language'] = lang_meta[0][0] if lang_meta else "en"
logger.info(f"书籍: {metadata['title']} - {metadata['author']}")
except Exception as e:
logger.warning(f"提取元数据时出错: {e}")
# 设置保底值
metadata.setdefault('title', 'Unknown')
metadata.setdefault('author', 'Unknown')
metadata.setdefault('language', 'en')
return metadata
def extract_all_content_items(self) -> List[Dict[str, Any]]:
"""
提取所有可翻译的内容项目(文档)。
遍历 EPUB 中的所有 Item,筛选出类型为 ITEM_DOCUMENT 的项目。
同时会进行简单的过滤,跳过内容过短(<100字符)或看起来像非正文的文件(如 nav, toc, cover)。
Returns:
List[Dict[str, Any]]: 内容项目列表。每个字典包含:
- item (epub.EpubItem): 原始 Item 对象。
- file_name (str): 文件名。
- content (str): 解码后的 HTML 内容。
- text_length (int): 纯文本长度(用于统计)。
"""
content_items = []
# 获取所有文档类型的项目
for item in self.book.get_items():
if item.get_type() == ebooklib.ITEM_DOCUMENT:
try:
# 获取内容 (bytes -> str)
content = item.get_content().decode('utf-8')
# 简单的内容验证:提取纯文本检查长度
soup = BeautifulSoup(content, 'html.parser')
text = soup.get_text().strip()
# 1. 跳过太短的内容(可能是只有图片的页面、空页面)
if len(text) < 100:
logger.debug(f"跳过短内容: {item.get_name()} ({len(text)} 字符)")
continue
# 2. 跳过明显的非正文内容 (根据文件名判断)
name_lower = item.get_name().lower()
skip_patterns = ['cover', 'copyright', 'titlepage', 'halftitle',
'nav.xhtml', 'toc.xhtml']
if any(pattern in name_lower for pattern in skip_patterns):
logger.debug(f"跳过非正文内容: {item.get_name()}")
continue
content_items.append({
'item': item,
'file_name': item.get_name(),
'content': content,
'text_length': len(text)
})
logger.debug(f"添加内容项: {item.get_name()} ({len(text)} 字符)")
except Exception as e:
logger.warning(f"处理项目失败 {item.get_name()}: {e}")
continue
logger.info(f"提取了 {len(content_items)} 个内容项目")
return content_items
def get_book_info(self) -> Dict[str, str]:
"""
获取书籍的摘要信息。
Returns:
Dict[str, str]: 包含文件名、标题、作者、语言和文档数量的字典。
"""
# 统计内容项
document_count = sum(1 for item in self.book.get_items()
if item.get_type() == ebooklib.ITEM_DOCUMENT)
return {
'filename': self.epub_path.name,
'title': self.metadata.get('title', 'Unknown'),
'author': self.metadata.get('author', 'Unknown'),
'language': self.metadata.get('language', 'en'),
'document_count': document_count
}
+391
View File
@@ -0,0 +1,391 @@
"""
LLM 客户端模块 (LLM Client Module)
该模块负责与 OpenRouter API 进行交互,执行实际的翻译请求。
它包含速率限制逻辑,并处理翻译结果的解析和验证。
Classes:
RateLimiter: 简单的异步令牌桶速率限制器。
OpenRouterClient: 封装了 OpenAI 异步客户端的 OpenRouter 专用客户端。
"""
import asyncio
from openai import AsyncOpenAI
from typing import List, Dict, Optional
from loguru import logger
import time
import re
class RateLimiter:
"""
异步速率限制器 (Async Rate Limiter)。
用于控制 API 请求的频率,防止触发服务商的 Rate Limit 错误。
同时控制每分钟请求数 (RPM) 和并发请求数 (Concurrent Requests)。
Attributes:
requests_per_minute (int): 每分钟允许的最大请求数。
semaphore (asyncio.Semaphore): 控制并发数的信号量。
last_request_time (float): 上一次请求的时间戳。
min_interval (float): 两次请求之间的最小间隔(秒)。
"""
def __init__(self, requests_per_minute: int, concurrent_requests: int):
"""
初始化速率限制器。
Args:
requests_per_minute (int): RPM 限制。
concurrent_requests (int): 最大并发数。
"""
self.requests_per_minute = requests_per_minute
self.semaphore = asyncio.Semaphore(concurrent_requests)
self.last_request_time = 0
self.min_interval = 60.0 / requests_per_minute if requests_per_minute > 0 else 0
async def acquire(self):
"""
获取请求许可。
首先获取信号量(控制并发),然后检查时间间隔(控制 RPM)。
如果请求过快,会执行 asyncio.sleep 进行等待。
"""
await self.semaphore.acquire()
if self.min_interval > 0:
current_time = time.time()
time_since_last = current_time - self.last_request_time
if time_since_last < self.min_interval:
await asyncio.sleep(self.min_interval - time_since_last)
self.last_request_time = time.time()
def release(self):
"""释放请求许可(释放信号量)。"""
self.semaphore.release()
class OpenRouterClient:
"""
OpenRouter API 客户端。
负责构建提示词、发送翻译请求、接收响应并解析回段落映射。
Attributes:
config (Dict): 配置字典。
client (AsyncOpenAI): OpenAI 异步客户端实例。
models (Dict): 模型配置字典。
rate_limiter (RateLimiter): 速率限制器实例。
"""
def __init__(self, config: Dict):
"""
初始化 LLM 客户端。
Args:
config (Dict): 全局配置字典,需包含 'openrouter' 部分。
Raises:
ValueError: 如果 API Key 未设置。
"""
self.config = config
openrouter_config = config['openrouter']
# 检查 API Key
api_key = openrouter_config.get('api_key')
if not api_key or api_key == "YOUR_OPENROUTER_API_KEY":
raise ValueError("请在配置文件中设置有效的 OpenRouter API Key")
# 初始化客户端
self.client = AsyncOpenAI(
base_url=openrouter_config['base_url'],
api_key=api_key,
default_headers={
"HTTP-Referer": "https://github.com/epub-translator",
"X-Title": "EPUB Translator"
}
)
self.models = openrouter_config['models']
self.rate_limiter = RateLimiter(
openrouter_config['rate_limits']['requests_per_minute'],
openrouter_config['rate_limits']['concurrent_requests']
)
logger.info("OpenRouter 客户端初始化完成")
async def translate_chunk_with_ids(self, paragraphs: List[Dict],
model_type: str = "production") -> Dict[str, str]:
"""
翻译一个段落块 (Chunk)。
接收带全局 ID 的段落列表,构建提示词发送给 LLM,
并解析返回的文本,将其映射回 {global_id: translation}。
Args:
paragraphs (List[Dict]): 段落字典列表,每个需包含 'global_id' 和 'text'。
model_type (str): 使用的模型类型 ('production' 或 'test')。
Returns:
Dict[str, str]: 全局 ID 到翻译文本的映射。
如果翻译失败,值为特定的错误标记字符串。
"""
if not paragraphs:
return {}
try:
# 构建编号提示词
prompt = self._build_numbered_prompt(paragraphs)
model = self.models.get(model_type, self.models['production'])
# 发送翻译请求
response = await self._make_request(prompt, model)
if not response:
logger.error("翻译请求返回空结果")
return self._create_failure_map(paragraphs)
# 解析编号翻译
translations = self._parse_numbered_response(response, paragraphs)
# 验证并返回
return self._validate_and_map(paragraphs, translations)
except Exception as e:
logger.error(f"翻译chunk失败: {e}")
return self._create_failure_map(paragraphs)
def _build_numbered_prompt(self, paragraphs: List[Dict]) -> str:
"""
构建带全局 ID 的 Prompt。
Args:
paragraphs (List[Dict]): 段落列表。
Returns:
str: 格式化后的 Prompt 字符串。
"""
lines = [
"请将以下编号的英文段落翻译成中文。",
"",
"要求:",
"1. 保持编号顺序,按相同编号返回翻译",
"2. 准确传达原文含义,语言流畅自然",
"3. 使用标准简体中文",
"",
"原文:",
""
]
# 添加编号段落(使用全局ID)
for para in paragraphs:
lines.append(f"[{para['global_id']}] {para['text']}")
lines.extend([
"",
"请按以下格式返回翻译:",
"[p_0001] 第一段的中文翻译",
"[p_0002] 第二段的中文翻译",
"...",
"",
"只返回编号的中文翻译,不要包含其他内容。"
])
return "\n".join(lines)
def _parse_numbered_response(self, response: str, paragraphs: List[Dict]) -> Dict[str, str]:
"""
解析 LLM 返回的带编号文本。
尝试使用正则表达式 `[p_xxxx] content` 提取 ID 和内容。
如果解析结果缺失严重,尝试使用备用解析策略。
Args:
response (str): LLM 的原始响应文本。
paragraphs (List[Dict]): 原始请求的段落列表(用于校验)。
Returns:
Dict[str, str]: 解析出的 {id: translation} 映射。
"""
translations = {}
# 按行分割
lines = response.strip().split('\n')
for line in lines:
line = line.strip()
if not line:
continue
# 匹配格式:[p_0001] 翻译内容
match = re.match(r'\\[(p_\\d+)\\]\\s*(.*)', line)
if match:
global_id = match.group(1)
translation = match.group(2).strip()
# Double check: remove any potential leading ID tag that leaked into the translation
# e.g. if response was "[p_001] [p_001] text"
translation = re.sub(r'^\\[p_\\d+\\]\\s*', '', translation)
if translation:
translations[global_id] = translation
# 检查缺失的翻译
expected_ids = [p['global_id'] for p in paragraphs]
missing_ids = [pid for pid in expected_ids if pid not in translations]
if missing_ids:
logger.warning(f"缺少 {len(missing_ids)} 个翻译: {missing_ids[:5]}")
# 尝试备用解析
if len(translations) == 0:
translations = self._fallback_parse(response, paragraphs)
found_count = len(translations)
expected_count = len(paragraphs)
logger.debug(f"解析翻译: {found_count}/{expected_count} 个段落")
return translations
def _fallback_parse(self, response: str, paragraphs: List[Dict]) -> Dict[str, str]:
"""
备用解析方法:按行顺序分割。
注意:仅当行数完全匹配时才使用,否则宁可失败也不要错位。
Args:
response (str): 响应文本。
paragraphs (List[Dict]): 段落列表。
Returns:
Dict[str, str]: 映射字典。
"""
logger.debug("尝试使用备用解析方法")
# 移除可能的编号标记
cleaned = re.sub(r'\\[p_\\d+\\]\\s*', '', response)
# 按双换行分割
parts = [p.strip() for p in cleaned.split('\n\n') if p.strip()]
# 如果数量不匹配,尝试按单换行分割
if len(parts) != len(paragraphs):
parts = [p.strip() for p in cleaned.split('\n') if p.strip()]
# 只有当数量完全一致时才进行映射
if len(parts) == len(paragraphs):
translations = {}
for i, para in enumerate(paragraphs):
translations[para['global_id']] = parts[i]
logger.warning(f"备用解析成功: 匹配了 {len(parts)} 行")
return translations
else:
logger.warning(f"备用解析失败: 行数不匹配 (原文 {len(paragraphs)} vs 译文 {len(parts)})")
# 返回空字典,后续会被 _validate_and_map 标记为失败
return {}
def _validate_and_map(self, paragraphs: List[Dict],
translations: Dict[str, str]) -> Dict[str, str]:
"""
验证翻译结果并填充缺失项。
确保每个请求的段落都有对应的返回结果。
如果缺失,填充错误标记。
Args:
paragraphs (List[Dict]): 原始段落列表。
translations (Dict[str, str]): 解析出的翻译。
Returns:
Dict[str, str]: 完整的映射。
"""
validated = {}
for para in paragraphs:
global_id = para['global_id']
translation = translations.get(global_id, "")
# 基本验证
if not translation:
validated[global_id] = f"[翻译失败 - 未返回翻译 - {global_id}]"
elif not self._is_valid_translation(translation):
validated[global_id] = f"[翻译失败 - 质量不合格 - {global_id}]"
else:
validated[global_id] = translation
return validated
def _is_valid_translation(self, translation: str) -> bool:
"""
验证单个翻译是否合法。
检查项:
1. 是否包含错误标记。
2. 是否包含中文字符。
3. 长度是否过短。
Args:
translation (str): 翻译文本。
Returns:
bool: 是否有效。
"""
if translation.startswith('[翻译失败') or translation.startswith('[解析失败'):
return False
if not re.search(r'[\u4e00-\u9fff]', translation):
return False
if len(translation) < 1: # 放宽限制,允许极短翻译
return False
return True
def _create_failure_map(self, paragraphs: List[Dict]) -> Dict[str, str]:
"""创建全失败的映射(用于 API 错误时)。"""
return {
para['global_id']: f"[翻译失败 - API错误 - {para['global_id']}]"
for para in paragraphs
}
async def _make_request(self, prompt: str, model: str) -> str:
"""
执行实际的 API 请求。
使用速率限制器。
Args:
prompt (str): 提示词。
model (str): 模型名称。
Returns:
str: API 返回的内容字符串。
"""
await self.rate_limiter.acquire()
try:
response = await self.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是一位专业的英中翻译专家。请严格按照要求的格式返回翻译。"},
{"role": "user", "content": prompt}
],
temperature=self.config['translation'].get('temperature', 0.2),
max_tokens=8000 # 足够大的值
)
return response.choices[0].message.content.strip()
except Exception as e:
logger.error(f"API请求失败: {e}")
raise
finally:
self.rate_limiter.release()
async def close(self):
"""关闭 HTTP 客户端连接。"""
try:
await self.client.close()
logger.info("OpenRouter 客户端已关闭")
except Exception as e:
logger.warning(f"关闭客户端时出错: {e}")
+343
View File
@@ -0,0 +1,343 @@
"""
文本处理器模块 - 重构版
实现全局编号系统,确保段落精确对应 + 清理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
}
+368
View File
@@ -0,0 +1,368 @@
"""
EPUB 翻译器核心模块 (EPUB Translator Core Module)
协调整个翻译流程:
1. 解析 EPUB。
2. 提取文本。
3. 分块并并发调用 LLM 翻译。
4. 缓存管理。
5. 重组生成双语 EPUB。
Classes:
EPUBTranslator: 翻译器主类。
"""
import asyncio
from typing import List, Dict
from pathlib import Path
from loguru import logger
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
from rich.table import Table
from rich.live import Live
from .epub_parser import EPUBParser
from .llm_client import OpenRouterClient
from .text_processor import TextProcessor
from .bilingual_builder import BilingualEPUBBuilder
from .cache import TranslationCache
class EPUBTranslator:
"""
EPUB 翻译器主控类。
Attributes:
config (Dict): 全局配置。
console (Console): Rich 库的控制台对象,用于漂亮输出。
use_cache (bool): 是否启用缓存。
parser (EPUBParser): EPUB 解析器实例。
llm_client (OpenRouterClient): LLM 客户端实例。
text_processor (TextProcessor): 文本处理器实例。
cache (TranslationCache): 缓存管理器实例。
concurrent_limit (int): 最大并发数。
"""
def __init__(self, config: Dict, use_cache: bool = True):
"""
初始化翻译器。
Args:
config (Dict): 配置字典。
use_cache (bool): 覆盖配置的缓存启用开关。
"""
self.config = config
self.console = Console()
self.use_cache = use_cache and config.get('cache', {}).get('enabled', True)
# 初始化组件
self.parser = None
self.llm_client = OpenRouterClient(config)
self.text_processor = TextProcessor(config)
self.cache = TranslationCache(config) if self.use_cache else None
# 并发控制
self.concurrent_limit = config['openrouter']['rate_limits']['concurrent_requests']
logger.info(f"EPUB 翻译器初始化完成,缓存: {'启用' if self.use_cache else '禁用'}, "
f"并发数: {self.concurrent_limit}")
async def translate_epub(self, epub_path: str,
test_mode: bool = False,
output_dir: str = None) -> str:
"""
执行 EPUB 翻译的主流程。
Args:
epub_path (str): 源 EPUB 文件路径。
test_mode (bool): 是否仅翻译前几段进行测试。
output_dir (str): 自定义输出目录。
Returns:
str: 生成的双语 EPUB 文件路径。
Raises:
Exception: 翻译过程中发生的任何未捕获异常。
"""
try:
# 初始化解析器
self.parser = EPUBParser(epub_path)
# 显示书籍信息
self._display_book_info()
if test_mode:
return await self._run_test_mode()
else:
return await self._run_full_translation(output_dir)
except Exception as e:
logger.error(f"翻译过程失败: {e}")
self.console.print(f"[red]翻译失败: {e}[/red]")
raise
finally:
await self.llm_client.close()
def _display_book_info(self):
"""在控制台显示书籍元数据表格。"""
book_info = self.parser.get_book_info()
table = Table(title="书籍信息")
table.add_column("属性", style="cyan")
table.add_column("值", style="white")
table.add_row("文件名", str(book_info['filename']))
table.add_row("标题", str(book_info['title']))
table.add_row("作者", str(book_info['author']))
table.add_row("语言", str(book_info['language']))
table.add_row("文档数", str(book_info['document_count']))
self.console.print(table)
async def _run_test_mode(self) -> Dict:
"""
执行测试模式:仅翻译开头的一小部分。
Returns:
Dict: 测试结果摘要。
"""
self.console.print("[yellow]运行测试模式...[/yellow]")
try:
# 提取所有内容
content_items = self.parser.extract_all_content_items()
if not content_items:
return {'status': 'failed', 'error': '未找到内容'}
# 只测试第一个内容项的前几个段落
first_item = content_items[0]
paragraphs = self.text_processor.extract_paragraphs_with_global_id(
first_item['content'],
first_item['file_name']
)
if not paragraphs:
return {'status': 'failed', 'error': '未找到段落'}
# 测试前3个段落
test_paragraphs = paragraphs[:3]
self.console.print(f"测试翻译 {len(test_paragraphs)} 个段落...")
# 翻译
translations = await self.llm_client.translate_chunk_with_ids(
test_paragraphs,
model_type="test"
)
# 显示结果
for para in test_paragraphs:
global_id = para['global_id']
translation = translations.get(global_id, "[未找到翻译]")
self.console.print(f"\n[cyan]{global_id}[/cyan]")
self.console.print(f"[green]原文:[/green] {para['text'][:100]}...")
self.console.print(f"[blue]译文:[/blue] {translation[:100]}...")
return {
'status': 'success',
'tested_paragraphs': len(test_paragraphs),
'translations': translations
}
except Exception as e:
logger.error(f"测试模式失败: {e}")
return {'status': 'failed', 'error': str(e)}
async def _run_full_translation(self, output_dir: str = None) -> str:
"""
执行完整翻译模式。
Returns:
str: 输出文件路径。
"""
self.console.print("[green]开始完整翻译...[/green]")
# 1. 提取所有内容
content_items = self.parser.extract_all_content_items()
if not content_items:
raise ValueError("未找到需要翻译的内容")
# 2. 提取所有段落(带全局ID)
all_paragraphs = []
paragraph_to_file_map = {} # 记录段落属于哪个文件
for item in content_items:
paragraphs = self.text_processor.extract_paragraphs_with_global_id(
item['content'],
item['file_name']
)
# 记录每个段落属于哪个文件
for para in paragraphs:
paragraph_to_file_map[para['global_id']] = {
'file_name': item['file_name'],
'text': para['text'],
'html_element': para['html_element']
}
all_paragraphs.extend(paragraphs)
logger.info(f"共提取 {len(all_paragraphs)} 个段落")
# 显示统计信息
stats = self.text_processor.get_statistics(all_paragraphs)
self.console.print(f"\n[cyan]段落统计:[/cyan]")
self.console.print(f" 总段落数: {stats['total_paragraphs']}")
self.console.print(f" 总字符数: {stats['total_characters']}")
self.console.print(f" 平均长度: {stats['average_length']}")
# 3. 创建 Chunks
chunks = self.text_processor.create_chunks_by_size(all_paragraphs)
self.console.print(f"\n[cyan]分块信息:[/cyan]")
self.console.print(f" Chunk数量: {len(chunks)}")
self.console.print(f" Chunk大小: {self.config['translation']['chunk_size']} 字符")
self.console.print(f" [yellow]并发翻译: {self.concurrent_limit} 个请求同时进行[/yellow]")
# 4. 并发翻译
translation_map = await self._translate_all_chunks_concurrent(chunks)
logger.info(f"完成翻译,共 {len(translation_map)} 个段落")
# 5. 构建双语 EPUB
output_path = output_dir or self.config['output']['output_dir']
builder = BilingualEPUBBuilder(self.parser.book, self.config)
result_file = builder.create_bilingual_epub_with_mapping(
translation_map,
paragraph_to_file_map,
output_path
)
# 显示缓存统计
if self.cache:
cache_stats = self.cache.get_cache_stats()
self.console.print(f"\n[cyan]缓存统计: {cache_stats.get('total_files', 0)} 个文件, "
f"{cache_stats.get('total_paragraphs', 0)} 个段落[/cyan]")
self.console.print(f"\n[green]✅ 翻译完成!输出文件: {result_file}[/green]")
return result_file
async def _translate_all_chunks_concurrent(self, chunks: List[List[Dict]]) -> Dict[str, str]:
"""
并发翻译所有 chunks。
使用 asyncio.gather 并发执行,利用 Semaphore 控制并发数。
Args:
chunks (List[List[Dict]]): 待翻译的 chunk 列表。
Returns:
Dict[str, str]: 合并后的全量翻译映射 {id: translation}。
"""
# 创建进度跟踪
total_chunks = len(chunks)
translation_map = {}
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TextColumn("({task.completed}/{task.total})"),
console=self.console
) as progress:
task_id = progress.add_task(
f"[cyan]并发翻译 (最多{self.concurrent_limit}个同时进行)",
total=total_chunks
)
# 创建所有翻译任务
tasks = [
self._translate_single_chunk(chunk, i, total_chunks, progress, task_id)
for i, chunk in enumerate(chunks, 1)
]
# 并发执行所有任务
results = await asyncio.gather(*tasks, return_exceptions=True)
# 处理结果
for i, result in enumerate(results, 1):
if isinstance(result, Exception):
logger.error(f"Chunk {i} 翻译失败: {result}")
# 为失败的chunk添加失败标记
chunk = chunks[i - 1]
for para in chunk:
translation_map[para['global_id']] = f"[翻译失败 - {para['global_id']}]"
elif isinstance(result, dict):
# 成功的翻译结果
translation_map.update(result)
else:
logger.warning(f"Chunk {i} 返回了意外的结果类型: {type(result)}")
logger.info(f"并发翻译完成,共处理 {len(translation_map)} 个段落")
return translation_map
async def _translate_single_chunk(self, chunk: List[Dict], chunk_index: int,
total_chunks: int, progress, task_id) -> Dict[str, str]:
"""
翻译单个 chunk(包含缓存查找逻辑)。
Args:
chunk (List[Dict]): 段落列表。
chunk_index (int): 当前 chunk 索引(用于日志)。
total_chunks (int): 总 chunk 数(用于日志)。
progress (Progress): 进度条对象。
task_id (TaskID): 进度条任务 ID。
Returns:
Dict[str, str]: 翻译结果映射。
"""
try:
# 1. 检查缓存
cached = None
if self.cache:
cached = self.cache.get_chunk_translation(
chunk,
self.llm_client.models.get('production', '')
)
if cached:
logger.debug(f"Chunk {chunk_index}/{total_chunks} 缓存命中")
progress.update(task_id, advance=1)
return cached
# 2. 调用 API 翻译
chunk_translations = await self.llm_client.translate_chunk_with_ids(
chunk,
model_type="production"
)
# 3. 保存缓存
if self.cache:
success = not any(t.startswith('[翻译失败')
for t in chunk_translations.values())
self.cache.save_chunk_translation(
chunk,
chunk_translations,
self.llm_client.models.get('production', ''),
success
)
logger.debug(f"Chunk {chunk_index}/{total_chunks} 翻译完成")
progress.update(task_id, advance=1)
return chunk_translations
except Exception as e:
logger.error(f"翻译chunk {chunk_index} 失败: {e}")
progress.update(task_id, advance=1)
# 返回失败标记
return {
para['global_id']: f"[翻译失败 - API错误 - {para['global_id']}]"
for para in chunk
}
+180
View File
@@ -0,0 +1,180 @@
"""
工具函数模块
提供配置加载、日志设置等通用功能
"""
import json
import os
from pathlib import Path
from typing import Dict, Any
from loguru import logger
import sys
def load_config(config_path: str = "config/config.json") -> Dict[str, Any]:
"""
加载配置文件
Args:
config_path: 配置文件路径
Returns:
配置字典
"""
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# 从环境变量获取 API Key
if 'OPENROUTER_API_KEY' in os.environ:
config['openrouter']['api_key'] = os.environ['OPENROUTER_API_KEY']
return config
except FileNotFoundError:
raise FileNotFoundError(f"配置文件未找到: {config_path}")
except json.JSONDecodeError as e:
raise ValueError(f"配置文件格式错误: {e}")
def load_prompts(prompts_path: str = "config/prompts.json") -> Dict[str, str]:
"""
加载提示词模板
Args:
prompts_path: 提示词文件路径
Returns:
提示词字典
"""
try:
with open(prompts_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
raise FileNotFoundError(f"提示词文件未找到: {prompts_path}")
def setup_logging(config: Dict[str, Any]) -> None:
"""
设置日志配置
Args:
config: 配置字典
"""
log_config = config.get('logging', {})
# 移除默认处理器
logger.remove()
# 添加控制台输出
logger.add(
sys.stdout,
level=log_config.get('level', 'INFO'),
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
)
# 添加文件输出
if 'file' in log_config:
log_file = log_config['file']
# 确保日志目录存在
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
logger.add(
log_file,
level=log_config.get('level', 'INFO'),
rotation=log_config.get('rotation', '10 MB'),
retention=log_config.get('retention', '7 days'),
encoding='utf-8',
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}"
)
def ensure_output_dir(output_dir: str) -> Path:
"""
确保输出目录存在
Args:
output_dir: 输出目录路径
Returns:
输出目录的 Path 对象
"""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
return output_path
def sanitize_filename(filename: str) -> str:
"""
清理文件名,移除非法字符
Args:
filename: 原始文件名
Returns:
清理后的文件名
"""
import re
# 移除或替换非法字符
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
# 移除多余的空格和点
filename = re.sub(r'\s+', ' ', filename).strip('. ')
return filename
def format_file_size(size_bytes: int) -> str:
"""
格式化文件大小显示
Args:
size_bytes: 字节数
Returns:
格式化的大小字符串
"""
if size_bytes == 0:
return "0B"
size_names = ["B", "KB", "MB", "GB"]
import math
i = int(math.floor(math.log(size_bytes, 1024)))
p = math.pow(1024, i)
s = round(size_bytes / p, 2)
return f"{s} {size_names[i]}"
def estimate_tokens(text: str) -> int:
"""
估算文本的 token 数量
Args:
text: 输入文本
Returns:
估算的 token 数量
"""
# 简单估算:英文约 4 字符/token,中文约 1.5 字符/token
import re
# 分离中英文
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
other_chars = len(text) - chinese_chars
# 估算 tokens
estimated_tokens = chinese_chars / 1.5 + other_chars / 4
return int(estimated_tokens)
def truncate_text(text: str, max_length: int = 100) -> str:
"""
截断文本用于显示
Args:
text: 原始文本
max_length: 最大长度
Returns:
截断后的文本
"""
if len(text) <= max_length:
return text
return text[:max_length-3] + "..."