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
+32
View File
@@ -0,0 +1,32 @@
"""
EPUB 双语翻译程序
主要功能模块的初始化文件
"""
__version__ = "0.08"
__author__ = "Kaitan"
from .epub_parser import EPUBParser
from .translator import EPUBTranslator
from .llm_client import LLMClient as OpenRouterClient # Keep alias for compatibility
from .llm_client import LLMClient
from .text_processor import TextProcessor
from .bilingual_builder import BilingualEPUBBuilder
from .chinese_builder import ChineseEPUBBuilder
from .format_extractor import FormatExtractor
from .format_restorer import FormatRestorer
from .utils import load_config, setup_logging
__all__ = [
"EPUBParser",
"EPUBTranslator",
"LLMClient",
"OpenRouterClient",
"TextProcessor",
"BilingualEPUBBuilder",
"ChineseEPUBBuilder",
"FormatExtractor",
"FormatRestorer",
"load_config",
"setup_logging"
]
+235
View File
@@ -0,0 +1,235 @@
"""
双语 EPUB 构建器模块 (集成 V2)
基于 FineGrainedExtractor 的 DOM 回填机制,确保 100% 的内容对齐和格式保留。
"""
from ebooklib import epub
import ebooklib
from bs4 import BeautifulSoup
from typing import Dict, List
from pathlib import Path
from loguru import logger
import uuid
from .fine_grained_extractor import FineGrainedExtractor
class BilingualEPUBBuilder:
"""双语 EPUB 构建器"""
def __init__(self, original_book, config: Dict):
self.original_book = original_book
self.config = config
# 从配置中获取是否翻译目录
self.translate_toc = config['translation'].get('translate_toc', False)
def create_bilingual_epub_with_mapping(self, translation_map: Dict[str, str],
paragraph_map: Dict[str, Dict],
output_path: str) -> str:
"""
创建双语 EPUB。使用 ordered_ids 确保与 Manifest 严格一致。
Args:
translation_map: { global_item_id: translated_text_with_ph }
paragraph_map: { global_item_id: item_metadata_dict }
"""
try:
new_book = epub.EpubBook()
self._copy_metadata(new_book)
# 安全清理 TOC (虽然预处理已做,但构建新书对象时再次确保合规)
new_book.toc = self._sanitize_toc(self.original_book.toc)
# 1. 准备每个文件的有序ID列表
# 目的是将扁平的 map 重新按文件和顺序组织
file_ordered_ids = {}
# paragraph_map 的 key 是 global_id,通常包含顺序信息或我们依赖 items 的插入顺序
# 更好的方式是依赖 item ID 的数字部分排序,如果它们是 'id_0', 'id_1'...
# 假设 ID 包含顺序信息。
sorted_pids = sorted(paragraph_map.keys(), key=lambda x: self._extract_id_index(x))
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)
processed_item_ids = set()
item_map = {}
# 特殊处理:封面图片
self._handle_cover(new_book, processed_item_ids, item_map)
# 2. 复制所有非文档资源 (图片, CSS, 字体)
for item in self.original_book.get_items():
if item.get_type() != ebooklib.ITEM_DOCUMENT:
if item.id not in processed_item_ids:
new_book.add_item(item)
processed_item_ids.add(item.id)
item_map[item.id] = item
# 3. 处理并回填文档
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
if item.get_type() == ebooklib.ITEM_DOCUMENT:
file_name = item.get_name()
new_item = item # 默认使用原 Item
# 如果该文件有翻译内容
if file_name in file_ordered_ids:
target_ids = file_ordered_ids[file_name]
# 执行回填
new_content = self._process_document_content(
item.get_content().decode('utf-8'),
file_name,
target_ids,
translation_map
)
# 创建新 item 避免污染原对象
new_item = epub.EpubHtml(
title=item.title,
file_name=file_name,
lang='zh-CN', # 双语版主要语言
uid=item.id
)
new_item.set_content(new_content.encode('utf-8'))
# 复制原 item 的其他属性如 style
for link in item.get_links():
# 这里不做深度复制,简单引用
pass
# 重新添加 links (特别是 CSS)
# 注意: EpubHtml 构造时不会自动带原来的 links,需要手动加
# 但我们在 dirty hack 里,直接 set_content 了 HTML。
# 如果 HTML head 里有 link, ebooklib 可能会解析并注册?
# Ebooklib 的行为是: 只有通过 add_link 加的才会出现在 opf manifest。
# 我们需要把原 item 的 links 复制过来
if hasattr(item, 'links'):
for link in item.links:
new_item.add_link(**link) # 不是很安全,视 ebooklib 版本而定
if new_item.id not in processed_item_ids:
new_book.add_item(new_item)
processed_item_ids.add(new_item.id)
new_spine.append(new_item)
else:
if item.id in item_map:
new_spine.append(item_map[item.id])
new_book.spine = new_spine
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}")
return output_file
except Exception as e:
logger.error(f"创建双语 EPUB 失败: {e}", exc_info=True)
raise
def _process_document_content(self, content: str, file_name: str,
target_ids: List[str], translation_map: Dict[str, str]) -> str:
"""
处理单个文档的内容:提取 -> 注入翻译 -> 回填
"""
try:
# 1. 再次提取,建立 DOM 映射
# 必须使用与 TextProcessing 阶段完全一致的参数
extractor = FineGrainedExtractor(translate_toc=self.translate_toc)
items = extractor.extract(content, file_name)
# 2. 筛选出应该翻译的项目
translatable_items = [i for i in items if i['should_translate']]
# 3. 一致性检查
if len(translatable_items) != len(target_ids):
logger.error(
f"严重错误 [{file_name}]: 提取项数 ({len(translatable_items)}) "
f"与 Manifest 记录数 ({len(target_ids)}) 不一致! "
"将跳过此文件的翻译回填以防错位。"
)
# Fallback: 返回原始内容
return content
# 4. 注入翻译
for extract_item, pid in zip(translatable_items, target_ids):
translation = translation_map.get(pid)
if translation:
extract_item['translation'] = translation
# 5. 回填
# 获取输出模式
output_mode = self.config.get('output', {}).get('mode', 'bilingual')
bilingual_mode = (output_mode == 'bilingual')
new_html = extractor.backfill(items, bilingual=bilingual_mode)
return new_html
except Exception as e:
logger.error(f"处理文档内容失败 {file_name}: {e}", exc_info=True)
return content
def _extract_id_index(self, pid: str) -> int:
"""从 ID 字符串中提取数字索引用于排序 (如 'p_10' -> 10)"""
try:
# 尝试常见格式 p_123, id_456
parts = pid.split('_')
if len(parts) > 1 and parts[-1].isdigit():
return int(parts[-1])
return 0
except:
return 0
def _sanitize_toc(self, toc):
"""确保 TOC 中的所有节点都有 ID"""
for item in toc:
if isinstance(item, (epub.Link, epub.Section)):
if not getattr(item, 'uid', None):
item.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
elif isinstance(item, tuple) and len(item) == 2:
section, children = item
if isinstance(section, (epub.Link, epub.Section)):
if not getattr(section, 'uid', None):
section.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
self._sanitize_toc(children)
return toc
def _handle_cover(self, new_book, processed_item_ids, item_map):
cover_id_meta = self.original_book.get_metadata('OPF', 'cover')
if cover_id_meta:
cover_item = self.original_book.get_item_with_id(cover_id_meta[0][0])
if cover_item:
new_book.add_item(cover_item)
processed_item_ids.add(cover_item.id)
item_map[cover_item.id] = cover_item
# 复制 cover metadata
new_book.add_metadata('OPF', 'cover', cover_item.id)
def _copy_metadata(self, new_book):
for namespace, meta_dict in self.original_book.metadata.items():
for name, values in meta_dict.items():
for value, other in values:
if name and hasattr(name, 'lower') and name.lower() == 'identifier': continue
new_book.add_metadata(namespace, name, value, other)
new_book.add_metadata('DC', 'language', 'zh-CN')
new_book.set_identifier(f"bilingual-{uuid.uuid4().hex[:12]}")
def _generate_output_filename(self, output_path: str) -> str:
# 根据模式生成不同的后缀
output_mode = self.config.get('output', {}).get('mode', 'bilingual')
suffix = "chinese" if output_mode == "chinese" else "bilingual"
title_meta = self.original_book.get_metadata('DC', 'title')
title = title_meta[0][0] if title_meta else "bilingual_book"
safe_title = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_')]).strip()
Path(output_path).mkdir(parents=True, exist_ok=True)
return str(Path(output_path) / f"{safe_title}_{suffix}.epub")
+84
View File
@@ -0,0 +1,84 @@
'''Book Profiler Module
Features:
1. Automatically extract book samples to generate Book Profile (Genre, Style, Glossary).
'''
import json
import random
from pathlib import Path
from typing import Dict, List
from loguru import logger
from .manifest_manager import ManifestManager
from .llm_client import LLMClient
class BookProfiler:
def __init__(self, config: Dict, llm_client: LLMClient):
self.config = config
self.llm_client = llm_client
def extract_sample_text(self, manifest: ManifestManager, char_limit: int = 3000) -> str:
"""Extract sample text."""
items = manifest.get_items()
if not items: return ""
intro_text = []
for item in items[:50]:
if len(item.clean_text) > 50:
intro_text.append(item.clean_text)
body_text = []
body_items = [i for i in items[50:] if len(i.clean_text) > 80]
if body_items:
samples = random.sample(body_items, min(5, len(body_items)))
body_text = [i.clean_text for i in samples]
full_text = "\n\n".join(intro_text[:5] + body_text)
return full_text[:char_limit]
async def analyze_book(self, manifest: ManifestManager) -> Dict:
"""Generate Book Profile."""
existing_profile = manifest.data.get('metadata', {}).get('profile')
if existing_profile:
logger.info("Loaded existing Book Profile")
return existing_profile
sample = self.extract_sample_text(manifest)
if not sample: return {}
logger.info("Generating Book Profile...")
system_prompt = "You are a senior publishing editor. Analyze the text and output JSON."
user_prompt = f"""
Please analyze the following book excerpt.
Output JSON format:
{{
"genre": "Genre",
"style": "Style description",
"audience": "Target Audience",
"glossary": {{ "Term": "Chinese Translation" }},
"translation_instruction": "Specific instruction for translator"
}}
Excerpt:
{sample}
"""
try:
response = await self.llm_client.raw_chat_completion(system_prompt, user_prompt)
json_str = response.strip()
if "```json" in json_str:
json_str = json_str.split("```json")[1].split("```")[0].strip()
elif "```" in json_str:
json_str = json_str.split("```")[1].split("```")[0].strip()
profile = json.loads(json_str)
if 'metadata' not in manifest.data:
manifest.data['metadata'] = {}
manifest.data['metadata']['profile'] = profile
manifest.save()
return profile
except Exception as e:
logger.error(f"Profile generation failed: {e}")
return {}
+226
View File
@@ -0,0 +1,226 @@
"""
翻译缓存管理模块 - 简化版
基于全局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:
"""获取缓存文件路径"""
# 使用 hash 前缀分目录,避免单目录文件过多
subdir = cache_key[:2]
cache_subdir = self.translations_dir / subdir
cache_subdir.mkdir(parents=True, exist_ok=True)
return cache_subdir / 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 not translations:
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)}
+226
View File
@@ -0,0 +1,226 @@
"""
纯中文 EPUB 构建器模块
负责:
1. 复制原始 EPUB 结构
2. 使用译文替换原文
3. 调用 FormatRestorer 将译文占位符还原为 HTML 标签
"""
from ebooklib import epub
import ebooklib
from bs4 import BeautifulSoup
from typing import Dict, List, Any
from pathlib import Path
from loguru import logger
import uuid
from .fine_grained_extractor import FineGrainedExtractor
class ChineseEPUBBuilder:
"""纯中文 EPUB 构建器 (DOM Safe)"""
def __init__(self, original_book, config: Dict):
self.original_book = original_book
self.config = config
self.translate_toc = config['translation'].get('translate_toc', False)
def create_chinese_epub_with_mapping(self,
items: List[Any], # List[ManifestItem]
output_path: str) -> str:
"""
创建纯中文 EPUB。
"""
try:
new_book = epub.EpubBook()
self._copy_metadata(new_book)
# 安全清理 TOC
new_book.toc = self._sanitize_toc(self.original_book.toc)
# 1. 按文件分组 Manifest Items
# 假设 items 已经是按全局 ID 排序的 (ManifestManager.get_items 返回有序列表)
file_items_map = {}
for item in items:
fname = item.source_file
if fname not in file_items_map:
file_items_map[fname] = []
file_items_map[fname].append(item)
processed_item_ids = set()
item_map = {}
# 特殊处理:封面图片
# 复制资源
for item in self.original_book.get_items():
if item.get_type() != ebooklib.ITEM_DOCUMENT:
if item.id not in processed_item_ids:
new_book.add_item(item)
processed_item_ids.add(item.id)
item_map[item.id] = item
# 重建 Spine
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
if item.get_type() == ebooklib.ITEM_DOCUMENT:
file_name = item.get_name()
if file_name in file_items:
new_item = self._create_chinese_document(
item, file_items[file_name]
)
new_item.id = item.id
else:
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)
new_spine.append(new_item)
else:
if item.id in item_map:
new_spine.append(item_map[item.id])
new_book.spine = new_spine
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, {})
return output_file
except Exception as e:
logger.error(f"创建纯中文 EPUB 失败: {e}", exc_info=True)
raise
def _sanitize_toc(self, toc):
"""确保 TOC 中的所有节点都有 ID"""
for item in toc:
if isinstance(item, (epub.Link, epub.Section)):
if not getattr(item, 'uid', None):
item.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
elif isinstance(item, tuple) and len(item) == 2:
section, children = item
if isinstance(section, (epub.Link, epub.Section)):
if not getattr(section, 'uid', None):
section.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
self._sanitize_toc(children)
return toc
def _copy_metadata(self, new_book):
try:
for namespace, meta_dict in self.original_book.metadata.items():
for name, values in meta_dict.items():
for value, other in values:
if name and hasattr(name, 'lower') and name.lower() == 'identifier': continue
new_book.add_metadata(namespace, name, value, other)
new_book.add_metadata('DC', 'language', 'zh-CN')
new_book.set_identifier(f"chinese-{uuid.uuid4().hex[:12]}")
except Exception as e:
logger.error(f"元数据复制出错: {e}")
def _create_chinese_document(self, original_item, manifest_items: list):
try:
from .text_processor import TextProcessor
soup = BeautifulSoup(original_item.get_content().decode('utf-8'), 'html.parser')
# 获取文本元素
text_elements = TextProcessor.get_valid_text_elements(soup)
current_idx = 0
for element in text_elements:
if not TextProcessor.clean_element_text(element): continue
if current_idx < len(manifest_items):
m_item = manifest_items[current_idx]
# 只有当非导航元素时才尝试替换内容
if not TextProcessor.is_navigation_element(element):
# 检查是否是嵌套容器(需要特殊处理)
is_nested = TextProcessor.is_nested_container(element)
# 优先使用带格式的翻译,降级到纯文本翻译
if m_item.translation_with_original_html:
if is_nested:
self._replace_direct_content(element, m_item.translation_with_original_html)
else:
self._replace_content(element, m_item.translation_with_original_html)
elif m_item.translation:
# 降级:使用纯文本翻译(无格式)
if is_nested:
self._replace_direct_content(element, m_item.translation)
else:
self._replace_content(element, m_item.translation)
current_idx += 1
new_item = epub.EpubHtml(title=original_item.title, file_name=original_item.get_name(), lang='zh-CN')
new_item.set_content(str(soup).encode('utf-8'))
return new_item
except Exception as e:
logger.error(f"创建中文文档失败 {original_item.get_name()}: {e}")
return original_item
def _replace_content(self, element, translated_html: str):
"""用译文替换元素的 inner_html"""
try:
# 将译文 HTML 字符串解析为 BeautifulSoup 对象
new_soup = BeautifulSoup(translated_html, 'html.parser')
# 清空原元素并填入新内容
element.clear()
# 重要:必须先转换为 list,否则 append 会修改 contents 导致跳过元素
for content in list(new_soup.contents):
element.append(content)
except Exception as e:
logger.error(f"替换内容失败: {e}")
def _replace_direct_content(self, element, translated_html: str):
"""
替换嵌套容器元素的直接文本内容(保留子块级元素)
策略:
1. 保存所有子块级元素
2. 清空元素内容
3. 填入译文
4. 在末尾追加保存的子块
"""
try:
from bs4 import NavigableString
block_tags = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'li', 'td']
# 1. 保存所有子块级元素
saved_blocks = []
for child in element.find_all(block_tags, recursive=False):
# 只保存直接子元素中的块
saved_blocks.append(child.extract())
# 2. 额外保存嵌套在 span 等内联元素中的块
for inline in element.find_all(['span', 'a', 'em', 'strong'], recursive=True):
for child in inline.find_all(block_tags, recursive=False):
saved_blocks.append(child.extract())
# 3. 清空并填入译文
new_soup = BeautifulSoup(translated_html, 'html.parser')
element.clear()
for content in list(new_soup.contents):
element.append(content)
# 4. 追加保存的子块
for block in saved_blocks:
element.append(block)
except Exception as e:
logger.error(f"替换嵌套内容失败: {e}")
# 降级到普通替换
self._replace_content(element, translated_html)
def _generate_output_filename(self, output_path: str) -> str:
from .utils import sanitize_filename
title = self.original_book.get_metadata('DC', 'title')
clean_title = sanitize_filename(title[0][0]) if title else "chinese_book"
Path(output_path).mkdir(parents=True, exist_ok=True)
return str(Path(output_path) / f"{clean_title}_chinese.epub")
+178
View File
@@ -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
+175
View File
@@ -0,0 +1,175 @@
"""
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, Set, Optional
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 get_toc(self):
"""
获取书籍的目录结构 (Table of Contents)
Returns:
book.toc: ebooklib 的原始 TOC 结构
"""
return self.book.toc
def extract_all_content_items(self, include_files: Optional[Set[str]] = None) -> 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:
# 如果指定了 include_files,则只处理其中的文件
if include_files is not None:
item_name = item.get_name()
if item_name not in include_files:
logger.debug(f"跳过未选中的文件: {item_name}")
continue
try:
# 获取内容 (bytes -> str)
content = item.get_content().decode('utf-8')
# 简单的内容验证:提取纯文本检查长度
soup = BeautifulSoup(content, 'html.parser')
text = soup.get_text().strip()
# 跳过太短的内容(可能是只有图片的页面、空页面)
if len(text) < 100:
logger.debug(f"跳过短内容: {item.get_name()} ({len(text)} 字符)")
continue
# 注意:文件名跳过逻辑已移至 TOCParser.get_skip_files()
# 通过 include_files 参数在调用前过滤
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
}
+222
View File
@@ -0,0 +1,222 @@
"""
Fine-Grained Extractor (集成格式保护版)
负责从 EPUB 中提取文本,进行精细化处理,并负责最终的回填工作。
集成 FormatExtractor 以实现行内格式的保护。
"""
from bs4 import BeautifulSoup, Tag, NavigableString
from typing import List, Dict, Any, Tuple
import re
from loguru import logger
from .format_extractor import FormatExtractor
from .format_restorer import FormatRestorer
class FineGrainedExtractor:
"""细粒度提取与回填器"""
SKIP_TRANSLATION_PATTERNS = [
r'index\.x?html',
r'bibliography\.x?html',
r'endnotes?\.x?html',
r'footnotes?\.x?html',
]
TOC_PATTERNS = [
r'nav\.x?html',
r'toc\.x?html',
]
def __init__(self, translate_toc: bool = False):
self.translate_toc = translate_toc
self.soup = None
# 初始化格式处理器
self.format_extractor = FormatExtractor()
self.format_restorer = FormatRestorer()
def extract(self, html_content: str, file_name: str = "") -> List[Dict[str, Any]]:
"""
提取所有正文元素 (p, h1-h6),并进行格式分析
"""
self.soup = BeautifulSoup(html_content, 'html.parser')
# 不要移除 link/style/meta/script,否则回填时会丢失头部信息
# for element in self.soup(['script', 'style', 'meta', 'link']):
# element.decompose()
doc_type = self._classify_document(file_name)
items = []
# 目标: 所有段落和标题
target_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
for element in self.soup.find_all(target_tags):
# 1. 初步文本提取 (用于过滤判断)
raw_text = element.get_text(separator=' ', strip=True)
if not raw_text.strip():
continue
# 2. 判断是否需要翻译
is_decorative = self._is_decorative(raw_text)
should_translate = self._should_translate(doc_type, is_decorative, raw_text)
item_data = {
'element': element, # 持有引用,用于回填
'tag': element.name,
'raw_text': raw_text,
'should_translate': should_translate,
'doc_type': doc_type,
'text_len': len(raw_text)
}
# 3. 如果需要翻译,执行通过 FormatExtractor 进行精细化格式提取
if should_translate:
# 必须传入 Outer HTML (str(element)),因为 FormatExtractor 内部会解析并剥离最外层标签
# 如果只传 inner_htmlFormatExtractor 解析时只能拿到第一个子节点,导致内容丢失验证失败
outer_html = str(element)
# 提取 (clean_text, text_with_ph, map, type, endnote_anchors)
clean, text_ph, ph_map, _, endnote_anchors = self.format_extractor.extract(outer_html)
# 再次验证:如果提取后的 clean_text 为空 (比如全是公式),则不翻译
if not clean.strip() or not text_ph.strip():
item_data['should_translate'] = False
else:
item_data['text'] = clean # 纯文本 (供人阅读/日志)
item_data['text_nodes'] = [] # 兼容旧字段 (空)
item_data['text_with_ph'] = text_ph # 发送给 LLM 的文本
item_data['placeholder_map'] = ph_map
item_data['endnote_anchors'] = endnote_anchors # 尾注锚点 ID 列表
else:
# 不需要翻译,仅保留基础信息
item_data['text'] = raw_text
item_data['text_with_ph'] = raw_text # Fallback
items.append(item_data)
logger.debug(f"[{doc_type}] {file_name}: 提取 {len(items)} 元素, 需翻译 {sum(1 for i in items if i['should_translate'])}")
return items
def backfill(self, items: List[Dict[str, Any]], bilingual: bool = True) -> str:
"""
回填翻译 (支持格式还原)
Args:
items: 提取的元素列表,且已注入 'translation' 字段 (带占位符的译文)
bilingual: 是否生成双语版本
"""
success_count = 0
for item in items:
if not item.get('should_translate'):
continue
# 使用预先注入的翻译 (解决了重复文本映射问题)
translated_ph = item.get('translation')
if not translated_ph:
continue
original_element = item['element']
# 4. 格式还原
ph_map = item.get('placeholder_map', {})
# 容错:如果 ph_map 为 None (未开启格式保护),设为空字典
if ph_map is None: ph_map = {}
restored_html, _ = self.format_restorer.restore(translated_ph, ph_map)
# 5. 构建新 DOM 元素
if bilingual:
# 双语模式: Append
new_tag = self.soup.new_tag(original_element.name)
# 继承 class
classes = original_element.get('class', [])
new_tag['class'] = list(classes) + ['translation', 'chinese']
# 继承 style
style = original_element.get('style')
if style:
new_tag['style'] = style
# 设置内容 (解析 restored HTML)
inner_soup = BeautifulSoup(restored_html, 'html.parser')
if inner_soup.body:
for child in list(inner_soup.body.children):
new_tag.append(child)
else:
for child in list(inner_soup.children):
new_tag.append(child)
original_element.insert_after(new_tag)
else:
# 仅中文模式: Replace
# 直接修改 original_element 的内容
original_element.clear()
inner_soup = BeautifulSoup(restored_html, 'html.parser')
# 直接替换内容
if inner_soup.body:
for child in list(inner_soup.body.children):
original_element.append(child)
else:
for child in list(inner_soup.children):
original_element.append(child)
# 可以在这里移除 dropcap class?
# 但如果 dropcap 是内部 span,已经被还原回去了。
# 由于 Drop Cap 处理在 FormatExtractor 已经把首字母放入文本,Prefix 里的 dropcap 是空的
# 还原后的 HTML 大概是 <span class="dropcap"></span>这...
pass
success_count += 1
return str(self.soup)
# --- 以下是辅助判别逻辑 (同原版) ---
def _classify_document(self, file_name: str) -> str:
if not file_name: return 'core'
fname = file_name.lower()
if any(re.search(p, fname) for p in self.SKIP_TRANSLATION_PATTERNS): return 'skip'
if any(re.search(p, fname) for p in self.TOC_PATTERNS): return 'toc'
return 'core'
def _should_translate(self, doc_type: str, is_decorative: bool, text: str = "") -> bool:
if is_decorative: return False
if self._is_roman_numeral(text): return False
if doc_type == 'core': return True
if doc_type == 'toc': return self.translate_toc
return False
def _is_roman_numeral(self, text: str) -> bool:
text = text.strip().upper()
if not text: return False
# 简单宽松匹配: 纯字母且看起来像罗马数字 (I, V, X, L, C, M)
# 排除普通单词如 "I" (作为代词时应翻译,但作为单独段落通常是标题)
# 这是一个权衡。单独的 "I" 在小说里可能表示 "我",但在章节标题里表示 "第一章"。
# 如果是正文中的 "I am...", 肯定会被提取。这里只有单独的 "I" 才会被这里匹配。
# 真正的问题是:单独一行 "I" 表示 "我" 的情况极少。
pattern = re.compile(r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$")
return bool(pattern.match(text))
def _is_decorative(self, text: str) -> bool:
s = text.strip()
if not s: return False
if not any(c.isalnum() for c in s): return True
if len(s) > 20: return False
patterns = [
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾]+$',
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾\s]+$',
]
for p in patterns:
if re.match(p, s): return True
# 字符种类很少且包含非字母 (e.g. "* * *")
unique = set(s.replace(' ', ''))
if len(unique) <= 3 and (unique & set('*-_~•◆◇■□▪▫●○◉◎⦿⦾')):
return True
return False
+580
View File
@@ -0,0 +1,580 @@
"""
格式提取模块 (优化版 v3)
核心优化:
1. 前缀/后缀标签分离:文本前后的标签不发送给 LLM,直接回填
2. 公式检测:将数学变量/公式作为整体占位符
3. 简化占位符:φ1φ 格式,每个段落独立编号
"""
import re
from bs4 import BeautifulSoup, Tag, NavigableString
from typing import Tuple, Dict, Any, List, Optional
class HeadingDetector:
"""标题与段落类型检测器"""
CHAPTER_PATTERNS = [
r'^(chapter|chap\.?|part)\s+([0-9]+|[ivxlc]+|[a-z])',
r'^(第\s*[0-9一二三四五六七八九十百]+\s*[章节部篇])',
r'^(\d+|[IVXLC]+|[A-Z])\.$'
]
EPIGRAPH_CLASSES = {
'epigraph', 'quote', 'blockquote', 'motto',
'dedication', 'verse', 'poetry', 'poem'
}
def detect(self, element: Tag, text: str) -> str:
if self._is_epigraph(element):
return "epigraph"
tag_name = element.name.lower()
if tag_name in ['h1', 'h2']:
return "chapter" if self._matches_chapter_pattern(text) else "section"
if tag_name == 'h3':
return "section"
if tag_name in ['h4', 'h5', 'h6']:
return "subsection"
if self._is_pseudo_heading(element, text):
return "subsection"
return "body"
def _is_epigraph(self, element: Tag) -> bool:
if element.name == 'blockquote':
return True
current = element
for _ in range(3):
if not current: break
classes = current.get('class', [])
if isinstance(classes, list):
classes = ' '.join(classes)
if any(k in classes.lower() for k in self.EPIGRAPH_CLASSES):
return True
current = current.parent
return False
def _matches_chapter_pattern(self, text: str) -> bool:
text = text.strip().lower()
for pattern in self.CHAPTER_PATTERNS:
if re.match(pattern, text, re.IGNORECASE):
return True
return False
def _is_pseudo_heading(self, element: Tag, text: str) -> bool:
if element.name != 'p':
return False
text = text.strip()
if not text or len(text) > 80:
return False
children = list(element.children)
if len(children) == 1 and isinstance(children[0], Tag):
if children[0].name in ['strong', 'b']:
return True
return False
class FormatExtractor:
"""
HTML 格式提取器 (优化版 v3)
核心改进:
1. 前缀/后缀标签分离 - 不发送给 LLM,自动回填
2. 公式元素整体替换
3. 简化占位符格式 φ1φ, φ2φ
"""
FORMULA_CHARS = re.compile(
r'^[\d\s\+\-\×\÷\=\(\)\[\]\{\}\<\>\^\*\/\.\,\;\:\'\"\`\~\@\#\$\%\&\|\\'
r'αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ'
r'a-zA-Z]+$'
)
def __init__(self):
self.detector = HeadingDetector()
def extract(self, element_html: str) -> Tuple[str, str, Dict[str, str], str, List[str]]:
"""
提取格式信息
Returns:
clean_text: 纯文本
text_with_placeholders: 只包含内嵌占位符的文本(不含前缀/后缀标签)
placeholder_map: 占位符映射,包含特殊键 "_prefix""_suffix"
paragraph_type: 段落类型
endnote_anchors: 尾注锚点 ID 列表 (用于补救)
"""
soup = BeautifulSoup(element_html, 'html.parser')
root = list(soup.children)[0] if list(soup.children) else soup
# 获取纯文本和段落类型
clean_text = root.get_text().strip()
clean_text = re.sub(r'\s+', ' ', clean_text)
p_type = self.detector.detect(root, clean_text) if isinstance(root, Tag) else "body"
# 获取内部 HTML
inner_html = root.decode_contents() if isinstance(root, Tag) else str(root)
# 智能提取(分离前缀/后缀)
text_with_ph, local_map = self._smart_extract_v3(inner_html)
# 核心修复:清理 text_with_placeholders 中的换行符和多余空格
# 这一步至关重要,因为 inner_html 中的换行符会导致 LLM Prompt 格式混乱(多行)
# 从而导致 LLM 忽略不在同一行的占位符或内容
if text_with_ph:
text_with_ph = re.sub(r'\s+', ' ', text_with_ph).strip()
# === 验证完整性 ===
# 将 text_with_placeholders 去掉占位符后与 clean_text 比较
stripped_text = self._strip_placeholders(text_with_ph)
stripped_text = re.sub(r'\s+', ' ', stripped_text).strip()
if not self._verify_content_integrity(clean_text, stripped_text):
# 内容不完整,降级到简单模式
from loguru import logger
logger.warning(f"内容验证失败,降级处理: '{clean_text[:30]}...'")
# 降级:不使用前缀/后缀分离,只做简单占位符处理
text_with_ph, local_map = self._fallback_extract(inner_html, clean_text)
# === 识别尾注锚点 ===
# 尾注锚点特征:<span id="aXXX"></span> (短随机ID,通常 3-5 字符)
endnote_anchors = []
for pid, html in local_map.items():
if pid.startswith("_"):
continue # 跳过 _prefix, _suffix
# 匹配空锚点:<span id="aXXX"></span> 或 <a id="aXXX"></a>
if re.match(r'<(span|a)\s+id="[a-zA-Z][a-zA-Z0-9]{2,5}"\s*>\s*</\1>', html):
endnote_anchors.append(pid)
return clean_text, text_with_ph, local_map, p_type, endnote_anchors
def _strip_placeholders(self, text: str) -> str:
"""移除所有占位符(φXφ 和 φ/Xφ 格式)"""
return re.sub(r'φ/?[0-9]+φ', '', text)
def _verify_content_integrity(self, clean_text: str, stripped_text: str) -> bool:
"""
验证内容完整性:比较 clean_text 和 stripped_text
允许一定的容差(空格差异、标点差异)
"""
# 标准化:移除空格和常见标点进行比较
def normalize(s):
s = re.sub(r'\s+', '', s) # 移除空格
s = s.lower() # 忽略大小写
return s
norm_clean = normalize(clean_text)
norm_stripped = normalize(stripped_text)
# 完全匹配
if norm_clean == norm_stripped:
return True
# 检查是否只是缺少少量字符(<5%)
if len(norm_stripped) > 0:
coverage = len(norm_stripped) / len(norm_clean) if norm_clean else 0
if coverage >= 0.95:
return True
return False
def _fallback_extract(self, inner_html: str, clean_text: str) -> Tuple[str, Dict[str, str]]:
"""
降级提取:不分离前缀/后缀,直接返回纯文本
"""
return clean_text, {"_prefix": "", "_suffix": ""}
def _smart_extract_v3(self, inner_html: str) -> Tuple[str, Dict[str, str]]:
"""
智能提取 v3:分离前缀/后缀 + 合并内嵌公式块
核心逻辑:
1. 分离前缀(第一个可翻译文本之前的完整标签)和后缀(最后一个可翻译文本之后的完整标签)
2. 中间部分:检测"公式块"(连续标签+不可翻译文本),合并为单个占位符
3. 只有真正需要翻译的格式标签(如斜体包裹的长文本)才拆分
注意:前缀/后缀只包含不影响文本结构的完整标签,开始标签必须有匹配的结束标签
"""
# 使用正则分割标签和文本
parts = re.split(r'(<[^>]+>)', inner_html)
parts = [p for p in parts if p]
if not parts:
return "", {"_prefix": "", "_suffix": ""}
# 识别每个部分的类型
part_types = [] # 'tag', 'translatable', 'formula', 'whitespace'
for part in parts:
if part.startswith('<'):
part_types.append('tag')
elif not part.strip():
part_types.append('whitespace')
elif self._is_translatable_text(part):
part_types.append('translatable')
else:
part_types.append('formula')
# 找到第一个和最后一个可翻译文本的索引
first_trans_idx = None
last_trans_idx = None
for i, t in enumerate(part_types):
if t == 'translatable':
if first_trans_idx is None:
first_trans_idx = i
last_trans_idx = i
if first_trans_idx is None:
# 没有可翻译文本,全部作为前缀
return "", {"_prefix": inner_html, "_suffix": ""}
# === 安全前缀分离 ===
# 只将自闭合标签和空白作为前缀,一旦遇到开始标签就停止
# 因为开始标签可能包裹着后面的可翻译文本
safe_prefix_end = 0
for i in range(first_trans_idx):
if part_types[i] == 'tag':
tag = parts[i]
# 检查是否是自闭合标签或结束标签(不太可能在开头)
is_self_closing = tag.endswith('/>') or re.match(r'<(br|hr|img|meta|link)\b', tag, re.I)
is_closing = tag.startswith('</')
# 检查是否是空元素(如 <span id="xxx"></span>,紧跟着结束标签)
is_empty_element = False
if not is_self_closing and not is_closing and i + 1 < first_trans_idx:
# 查看下一个标签是否是对应的结束标签
next_idx = i + 1
while next_idx < first_trans_idx and part_types[next_idx] in ('whitespace',):
next_idx += 1
if next_idx < first_trans_idx and part_types[next_idx] == 'tag':
next_tag = parts[next_idx]
if next_tag.startswith('</'):
# 检查标签名是否匹配
open_name = re.match(r'<(\w+)', tag)
close_name = re.match(r'</(\w+)', next_tag)
if open_name and close_name and open_name.group(1) == close_name.group(1):
is_empty_element = True
# 跳过这对空元素
safe_prefix_end = next_idx + 1
continue
if is_self_closing or is_closing:
safe_prefix_end = i + 1
elif is_empty_element:
pass # 已在上面处理
else:
# 遇到普通开始标签,停止
break
elif part_types[i] == 'whitespace':
safe_prefix_end = i + 1
else:
# formula 类型,不应该出现在前缀中
break
# === 安全后缀分离 ===
# 从末尾开始向前,只剥离连续的完整结束标签/自闭合标签或空白
safe_suffix_start = len(parts)
for i in range(len(parts) - 1, last_trans_idx, -1):
if part_types[i] == 'tag':
tag = parts[i]
is_closing = tag.startswith('</')
is_self_closing = tag.endswith('/>') or re.match(r'<(br|hr|img|meta|link)\b', tag, re.I)
if is_closing or is_self_closing:
safe_suffix_start = i
else:
# 遇到开始标签,不能作为独立后缀剥离
break
elif part_types[i] == 'whitespace':
safe_suffix_start = i
else:
# 遇到普通文本或公式,停止剥离
break
# 重新分割
prefix_parts = parts[:safe_prefix_end]
middle_parts = parts[safe_prefix_end:safe_suffix_start]
middle_types = part_types[safe_prefix_end:safe_suffix_start]
suffix_parts = parts[safe_suffix_start:]
# === Drop Cap 检测 ===
# 英文书籍常用首字母放大样式,如 <span class="dropcap">T</span>his
# 检测:前缀末尾是格式化的单个字母,且中间部分第一个文本以小写字母开头
# 处理:移除格式标签,将纯字母加入中间部分
if prefix_parts and middle_parts:
prefix_parts, middle_parts, middle_types = self._handle_drop_cap(
prefix_parts, middle_parts, middle_types
)
# 构建映射
local_map = {}
# 前缀
prefix_html = "".join(prefix_parts)
if prefix_html:
local_map["_prefix"] = prefix_html
# 后缀
suffix_html = "".join(suffix_parts)
if suffix_html:
local_map["_suffix"] = suffix_html
# 中间部分处理:使用配对占位符格式
#
# 策略:
# 1. 连续的 (tag|formula|whitespace) 不包含可翻译文本 → 合并为单个占位符 φ1φ
# 2. 开始标签后接可翻译文本 → 配对格式 φ2φ文本φ/2φ
#
placeholder_counter = 1
result_parts = []
tag_stack = [] # 追踪开放标签 [(id, opening_tag), ...]
i = 0
while i < len(middle_parts):
part = middle_parts[i]
ptype = middle_types[i]
if ptype == 'translatable':
# 可翻译文本,直接保留
result_parts.append(part)
i += 1
elif ptype == 'tag':
# 检查是开始标签还是结束标签
is_closing = part.startswith('</')
if is_closing:
# 结束标签
if tag_stack:
# 匹配最近的开放标签
open_id, open_tag = tag_stack.pop()
local_map[f"/{open_id}"] = part
result_parts.append(f"φ/{open_id}φ")
else:
# 没有匹配的开放标签,作为单独占位符
pid = str(placeholder_counter)
placeholder_counter += 1
local_map[pid] = part
result_parts.append(f"φ{pid}φ")
i += 1
else:
# 开始标签,检查后面是否有可翻译文本
has_translatable_after = False
for j in range(i + 1, len(middle_parts)):
if middle_types[j] == 'translatable':
has_translatable_after = True
break
elif middle_types[j] == 'tag' and middle_parts[j].startswith('</'):
# 遇到结束标签但还没遇到可翻译文本
break
if has_translatable_after:
# 开始配对模式
pid = str(placeholder_counter)
placeholder_counter += 1
local_map[pid] = part
result_parts.append(f"φ{pid}φ")
tag_stack.append((pid, part))
i += 1
else:
# 后面没有可翻译文本,合并为公式块
block_parts = []
while i < len(middle_parts) and middle_types[i] != 'translatable':
block_parts.append(middle_parts[i])
i += 1
if block_parts:
block_html = "".join(block_parts)
pid = str(placeholder_counter)
placeholder_counter += 1
local_map[pid] = block_html
result_parts.append(f"φ{pid}φ")
elif ptype in ('formula', 'whitespace'):
# 简化处理:非可翻译文本直接保留
# 公式检测等复杂逻辑仅在增强模式下启用
result_parts.append(part)
i += 1
else:
i += 1
text_with_ph = "".join(result_parts)
text_with_ph = re.sub(r'\s+', ' ', text_with_ph).strip()
# === 连续占位符合并 ===
# 将 φ1φφ2φ 这样的连续占位符合并为一个
def merge_consecutive_placeholders(text: str, ph_map: dict) -> Tuple[str, dict]:
"""合并连续占位符"""
# 匹配连续的占位符(2个或更多)
pattern = r'(φ/?\d+φ)(φ/?\d+φ)+'
def merge_match(m):
full_match = m.group(0)
# 提取所有占位符ID
pids = re.findall(r'φ(/?\d+)φ', full_match)
if len(pids) <= 1:
return full_match
# 合并对应的 HTML
merged_html = ""
for pid in pids:
if pid in ph_map:
merged_html += ph_map[pid]
del ph_map[pid]
# 创建新的合并占位符
new_pid = pids[0] if pids[0].isdigit() else pids[0][1:] # 使用第一个数字
ph_map[new_pid] = merged_html
return f"φ{new_pid}φ"
merged_text = re.sub(pattern, merge_match, text)
return merged_text, ph_map
text_with_ph, local_map = merge_consecutive_placeholders(text_with_ph, local_map)
return text_with_ph, local_map
def _handle_drop_cap(self, prefix_parts: List[str], middle_parts: List[str],
middle_types: List[str]) -> Tuple[List[str], List[str], List[str]]:
"""
处理 Drop Cap(首字母放大)样式
典型模式:<span class="dropcap">T</span>his is...
问题:前缀会包含 <span>T</span>,但 T 是 This 的一部分
处理:
1. 检测前缀末尾是否为 "格式化的单个大写字母"
2. 检测中间部分首个文本是否以小写字母开头
3. 如果两者组合成一个单词,移除格式,将纯字母加入中间部分
"""
if not prefix_parts or not middle_parts:
return prefix_parts, middle_parts, middle_types
# 提取前缀中的文本内容
prefix_text = ""
last_text_idx = -1
for i, part in enumerate(prefix_parts):
if not part.startswith('<'):
prefix_text = part.strip()
last_text_idx = i
# 检测是否为单个大写字母
if not prefix_text or len(prefix_text) != 1 or not prefix_text.isupper():
return prefix_parts, middle_parts, middle_types
# 检测中间部分第一个可翻译文本
first_middle_text = ""
first_middle_idx = -1
for i, (part, ptype) in enumerate(zip(middle_parts, middle_types)):
if ptype == 'translatable':
first_middle_text = part.strip()
first_middle_idx = i
break
if not first_middle_text:
return prefix_parts, middle_parts, middle_types
# Drop Cap 检测条件(满足任一即可):
# 1. 后续文本以小写字母开头: T + his = This
# 2. 后续文本以大写字母开头且紧连(无空格): I + N OCTOBER = IN OCTOBER
is_drop_cap = False
first_char = first_middle_text[0] if first_middle_text else ''
if first_char.islower():
# 条件1: This 模式
is_drop_cap = True
elif first_char.isupper():
# 条件2: IN OCTOBER 模式 - 检查是否紧连(第一个字母后不应有空格)
# 原始 HTML 中 </span>N 表示 N 紧跟在 I 后面
is_drop_cap = True
if not is_drop_cap:
return prefix_parts, middle_parts, middle_types
# 组合检测:大写字母 + 后续文本的第一个单词
combined = prefix_text + first_middle_text.split()[0] if first_middle_text else ""
# 验证:组合后是否为合理的英文单词/大写序列(至少 2 个字母)
if len(combined) >= 2 and combined.isalpha():
# 确认是 Drop Cap,移除格式
# 从前缀中移除这个字母和其包裹的格式标签
new_prefix = []
skip_until_close = False
found_letter = False
for part in prefix_parts:
if part.startswith('<') and not part.startswith('</'):
# 开始标签,准备跳过
skip_until_close = True
elif part.startswith('</'):
# 结束标签
if skip_until_close:
skip_until_close = False
continue
new_prefix.append(part)
elif part.strip() == prefix_text:
# 这是那个大写字母,跳过
found_letter = True
continue
else:
if not skip_until_close:
new_prefix.append(part)
if found_letter:
# 将大写字母加到中间部分第一个可翻译文本的开头
middle_parts = middle_parts.copy()
middle_parts[first_middle_idx] = prefix_text + middle_parts[first_middle_idx]
prefix_parts = new_prefix
return prefix_parts, middle_parts, middle_types
def _is_translatable_text(self, text: str) -> bool:
"""
判断文本是否需要翻译(包含可翻译的单词)
条件(满足任一即可):
1. 包含 3 个及以上连续字母(如 "and", "Art", "War"
2. 包含空格分隔的多个单词(如 "and Sun Tzu's"
3. 包含数字(如章节号 "10", "12"
"""
text = text.strip()
if not text:
return False
# 条件1: 3 个及以上连续字母
if re.search(r'[a-zA-Z]{3,}', text):
return True
# 条件2: 包含空格的多单词文本(如 "a of"
if ' ' in text and re.search(r'[a-zA-Z]', text):
return True
# 条件3: 包含数字(章节号等)
if re.search(r'\d', text):
return True
return False
def _is_formula_element(self, element: Tag, text_content: str) -> bool:
"""判断元素是否为公式元素(应整体保留不翻译)"""
if not text_content:
return True
if len(text_content) <= 3:
return True
if re.search(r'[a-zA-Z]{4,}', text_content):
return False
return bool(self.FORMULA_CHARS.match(text_content))
def _get_opening_tag(self, element: Tag) -> str:
"""获取元素的开始标签(含属性)"""
attrs_str = ""
for key, value in element.attrs.items():
if isinstance(value, list):
value = " ".join(value)
attrs_str += f' {key}="{value}"'
return f"<{element.name}{attrs_str}>"
def reset(self):
"""兼容旧接口"""
pass
def _is_pure_punctuation(self, text: str) -> bool:
"""判断文本是否仅包含标点符号和空格(不应该变成占位符)"""
# 常见标点符号集合(中英文混合)
punctuation_chars = ' ,.:;!?,。:;!?、""\'\'「」【】()()[]{}—-–…·'
return all(c in punctuation_chars for c in text)
+99
View File
@@ -0,0 +1,99 @@
"""
格式恢复模块 (优化版 v4)
负责:
1. 解析译文中的配对占位符 (φ1φ...φ/1φ)
2. 还原前缀和后缀标签(_prefix, _suffix
3. 从映射表中查找对应的 HTML 片段并替换
"""
import re
from typing import Dict, Tuple, List, Optional
from loguru import logger
class FormatRestorer:
"""
格式恢复器 (优化版 v4)
支持:
- 配对占位符 φ1φ...φ/1φ
- 前缀/后缀自动回填 (_prefix, _suffix)
"""
# 占位符正则: φ1φ, φ/1φ, φ12φ, φ/12φ (支持配对格式)
PLACEHOLDER_REGEX = re.compile(r'φ(/?\d+)φ')
def restore(self, text_with_placeholders: str, placeholder_map: Dict[str, str]) -> Tuple[str, bool]:
"""
将带占位符的文本还原为 HTML
自动处理 _prefix 和 _suffix 键,以及配对占位符 φ1φ...φ/1φ
Returns:
(html_string, success): 还原后的 HTML 和是否完全成功的标志
"""
if not placeholder_map:
return text_with_placeholders or "", True
if not text_with_placeholders:
# 没有文本,只有前缀/后缀(如空元素)
prefix = placeholder_map.get("_prefix", "")
suffix = placeholder_map.get("_suffix", "")
return prefix + suffix, True
# 提取前缀和后缀
prefix = placeholder_map.get("_prefix", "")
suffix = placeholder_map.get("_suffix", "")
# 创建只包含占位符键的映射(排除 _prefix, _suffix
inner_map = {k: v for k, v in placeholder_map.items() if not k.startswith("_")}
# 校验占位符
found_ids = set(self.PLACEHOLDER_REGEX.findall(text_with_placeholders))
expected_ids = set(inner_map.keys())
success = True
missing_ids = expected_ids - found_ids
if missing_ids:
logger.warning(f"格式还原警告: 丢失占位符 {missing_ids}")
success = False
unknown_ids = found_ids - expected_ids
if unknown_ids:
# 过滤掉冗余的闭合标签(例如 map里有 "1",但 LLM 输出了 "φ/1φ"
real_unknowns = set()
for pid in unknown_ids:
# 如果是 /N,且 N 在 map 中,则认为是安全的冗余闭合
if pid.startswith('/') and pid[1:] in expected_ids:
continue
real_unknowns.add(pid)
if real_unknowns:
logger.warning(f"格式还原警告: 发现未知占位符 {real_unknowns}")
success = False
# 替换占位符
def replace_match(match):
pid = match.group(1) # 可能是 "1" 或 "/1"
if pid in inner_map:
return inner_map[pid]
else:
return "" # 删除未知占位符
try:
# 还原占位符
restored_inner = self.PLACEHOLDER_REGEX.sub(replace_match, text_with_placeholders)
# 添加前缀和后缀
restored_html = prefix + restored_inner + suffix
return restored_html, success
except Exception as e:
logger.error(f"格式还原失败: {e}")
return prefix + self._strip_placeholders(text_with_placeholders) + suffix, False
def _strip_placeholders(self, text: str) -> str:
"""移除所有 φ...φ 占位符"""
return self.PLACEHOLDER_REGEX.sub("", text)
+290
View File
@@ -0,0 +1,290 @@
"""
LLM Client Module - Generic OpenAI Compatible
Features:
1. Fully configurable via config.json (base_url, headers).
2. Mode-aware prompt building (bilingual vs chinese).
3. Format repair capability for chinese mode.
"""
import asyncio
import json
import re
from openai import AsyncOpenAI
from typing import List, Dict, Optional, Any
from loguru import logger
import time
from tenacity import retry, stop_after_attempt, wait_exponential
from .manifest_manager import ManifestItem
class RateLimiter:
"""Rate limiter for concurrency and RPM."""
def __init__(self, requests_per_minute: int, concurrent_requests: int):
self.semaphore = asyncio.Semaphore(concurrent_requests)
self.min_interval = 60.0 / requests_per_minute if requests_per_minute > 0 else 0
self.last_request_time = 0
self._lock = asyncio.Lock()
async def acquire(self):
await self.semaphore.acquire()
async with self._lock:
current_time = time.time()
wait_time = self.min_interval - (current_time - self.last_request_time)
if wait_time > 0:
await asyncio.sleep(wait_time)
self.last_request_time = time.time()
def release(self):
self.semaphore.release()
class LLMClient:
"""Generic OpenAI-compatible API Client."""
def __init__(self, config: Dict):
self.config = config
llm_config = config["llm"]
api_key = llm_config.get("api_key")
base_url = llm_config.get("base_url")
extra_headers = llm_config.get("extra_headers", {})
if not api_key:
raise ValueError("API Key is missing in config")
self.client = AsyncOpenAI(
base_url=base_url,
api_key=api_key,
default_headers=extra_headers
)
self.models = llm_config.get("models", {"fast": "gpt-3.5-turbo", "smart": "gpt-4"})
self.rate_limiter = RateLimiter(
llm_config["rate_limits"]["requests_per_minute"],
llm_config["rate_limits"]["concurrent_requests"]
)
self.prompts = self._load_prompts()
def _load_prompts(self) -> Dict:
try:
with open("config/prompts.json", "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"严重错误:无法加载 config/prompts.json: {e}")
raise # 必须抛出异常,否则 System Prompt 会降级导致占位符指令丢失
async def translate_chunk(self, items: List[ManifestItem], glossary: Dict = None,
instruction: str = None, model_type: str = "fast",
mode: str = "bilingual") -> Dict[str, str]:
"""
Translate a chunk of items.
Args:
items: List of ManifestItem to translate
glossary: Term dictionary
instruction: Style guide
model_type: "fast" or "smart"
mode: "bilingual" or "chinese"
"""
if not items: return {}
model = self.models.get(model_type, self.models.get("fast"))
prompt = self._build_prompt(items, mode)
try:
# Build System Prompt
base_sys_prompt = self.prompts.get("translation", {}).get("system", "You are a professional translator.")
# 中文模式:Prompt 已在 config/prompts.json 中配置,无需额外硬编码
if mode == "chinese":
pass
if instruction:
base_sys_prompt += f"\n\nBook Style Guide:\n{instruction}"
if glossary:
glossary_text = "\n".join([f"{k} -> {v}" for k, v in glossary.items()])
base_sys_prompt += f"\n\nTerminology:\n{glossary_text}"
# Strict formatting instructions
base_sys_prompt += "\n\nRequirements:\n1. Each line MUST start with ID (p_xxxxx).\n2. DO NOT modify IDs.\n3. Return only translations."
# DEBUG: 打印发送给 LLM 的完整内容
logger.debug(f"=== LLM REQUEST DEBUG ===")
logger.debug(f"System Prompt:\n{base_sys_prompt[:500]}...")
logger.debug(f"User Prompt (first 1000 chars):\n{prompt[:1000]}")
logger.debug(f"=========================")
raw_response = await self._make_request(model, base_sys_prompt, prompt)
# DEBUG: 打印 LLM 返回的完整内容
logger.debug(f"=== LLM RESPONSE DEBUG ===")
logger.debug(f"Raw Response (first 1500 chars):\n{raw_response[:1500] if raw_response else 'EMPTY'}")
logger.debug(f"==========================")
if not raw_response:
return {item.global_id: f"[Error - Empty Response]" for item in items}
results = self._simple_parse(raw_response, items, mode)
return results
except Exception as e:
logger.error(f"Translation failed ({model}): {e}")
return {item.global_id: f"[Error - {str(e)}]" for item in items}
async def repair_format(self, original_text: str, broken_translation: str, missing_ids: set = None) -> str:
"""
修复翻译格式:将缺失的占位符正确插入到译文中。
Args:
original_text: 原文(带占位符)
broken_translation: 有占位符问题的译文
missing_ids: 缺失的占位符 ID 集合(可选,用于提示)
"""
model = self.models.get("fast")
system_prompt = """你是格式修复助手。你的任务是将缺失的占位符插入到译文中。
注意:
1. 不要重新翻译,保持中文译文内容完全不变
2. 只需要在正确位置插入缺失的占位符
3. 占位符格式:φ数字φ(如 φ1φ, φ/1φ)
4. 只输出修复后的译文,不要任何解释"""
missing_hint = ""
if missing_ids:
missing_list = ", ".join([f"φ{pid}φ" for pid in missing_ids])
missing_hint = f"\n缺失的占位符: {missing_list}"
user_prompt = f"""原文(带占位符):
{original_text}
当前译文(占位符有误):
{broken_translation}
{missing_hint}
请修复译文,在正确位置插入缺失的占位符。只输出修复后的译文:"""
try:
return await self._make_request(model, system_prompt, user_prompt)
except Exception as e:
logger.error(f"Format repair failed: {e}")
return broken_translation
async def raw_chat_completion(self, system_prompt: str, user_prompt: str, model_type: str = "smart") -> str:
"""Generic chat completion (for Profiler)."""
model = self.models.get(model_type, self.models.get("smart"))
return await self._make_request(model, system_prompt, user_prompt)
def _build_prompt(self, items: List[ManifestItem], mode: str = "bilingual") -> str:
"""构建翻译提示词"""
lines = []
for item in items:
if mode == "chinese":
# 中文模式:使用带占位符的文本和段落类型
twp = item.text_with_placeholders
# DEBUG: 打印关键信息
logger.debug(f"BUILD_PROMPT {item.global_id}: twp='{twp[:50] if twp else 'EMPTY'}...', has_φ={'φ' in twp if twp else False}")
text = twp if twp else item.clean_text
# 防御性修复:强制清理换行符,兼容旧的脏 Manifest 数据
text = re.sub(r'\s+', ' ', text).strip()
p_type = getattr(item, 'paragraph_type', 'body').upper()
lines.append(f"{item.global_id} [{p_type}] {text}")
else:
# 双语模式:使用纯文本
lines.append(f"{item.global_id} {item.clean_text}")
return "\n".join(lines)
def _simple_parse(self, response: str, items: List[ManifestItem], mode: str = "bilingual") -> Dict[str, str]:
"""
解析 LLM 响应 - 位置切分版
策略:
1. 识别响应中所有出现的 p_xxxxx 及其位置
2. 按位置顺序将响应切分成每一段,消除对输入顺序的依赖
"""
results = {}
valid_ids = {item.global_id for item in items}
# 1. 查找所有可能的 ID 位置
# 模式匹配 p_ 后面跟着 5 位数字
matches = list(re.finditer(r'p_\d{5}', response))
if not matches:
# Fallback: 如果没有匹配到任何 ID,尝试按行扫描
for line in response.split("\n"):
line = line.strip()
for it in items:
if line.startswith(it.global_id):
content = line[len(it.global_id):].strip().lstrip(": ")
if content: results[it.global_id] = content
return results
# 2. 按查找到的 ID 位置进行切割
for i, match in enumerate(matches):
current_id = match.group()
if current_id not in valid_ids:
continue
# 这一段内容的起始是当前 ID 之后,结束是下一个匹配的 ID 之前
start_pos = match.end()
end_pos = matches[i+1].start() if i + 1 < len(matches) else len(response)
content = response[start_pos:end_pos].strip()
# 3. 清理内容
content = content.lstrip(": \t")
if mode == "chinese":
# 移除 [BODY] 等类型标记
content = re.sub(r'^\[[A-Z]+\]\s*', '', content)
content = content.strip()
if content:
results[current_id] = content
# 验证解析结果
parsed_count = len(results)
expected_count = len(items)
if parsed_count < expected_count:
missing_ids = [item.global_id for item in items if item.global_id not in results]
logger.warning(f"LLM 响应解析不完整: {parsed_count}/{expected_count} (缺失: {missing_ids[:3]}...)")
return results
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
async def _make_request(self, model: str, system_prompt: str, user_prompt: str) -> str:
await self.rate_limiter.acquire()
try:
resp = await self.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=self.config['translation'].get('temperature', 0.2),
max_tokens=8000
)
return resp.choices[0].message.content.strip()
finally:
self.rate_limiter.release()
async def close(self):
await self.client.close()
# Alias for backward compatibility
OpenRouterClient = LLMClient
+189
View File
@@ -0,0 +1,189 @@
"""
Manifest 管理器模块 (Manifest Manager Module)
该模块是系统的单一真理源 (SSOT)。
它记录了每一段文本的原始状态、清洗后的文本、哈希值以及翻译状态。
所有对翻译流程的操作(提取、翻译、回填)都必须通过修改此 Manifest 进行。
"""
import json
import os
import hashlib
from typing import List, Dict, Optional, Any
from pathlib import Path
from loguru import logger
from dataclasses import dataclass, asdict, field
@dataclass
class ManifestItem:
"""代表一个翻译单元(通常是一个段落)"""
global_id: str
source_file: str
original_html: str
clean_text: str
text_hash: str
tag: str
tag_attrs: Dict[str, Any] = field(default_factory=dict) # 外层标签的属性 (class, style...)
translation: Optional[str] = None
status: str = "pending" # pending, translated, ignored, failed
error_msg: Optional[str] = None
model_used: Optional[str] = None # 记录使用的模型
quality_score: Optional[int] = None # 记录质量评分
# === 中文模式专用字段 ===
text_with_placeholders: str = "" # 带占位符的文本
placeholder_map: Dict[str, str] = field(default_factory=dict) # 占位符映射表 {id: html_string}
paragraph_type: str = "body" # 段落类型:chapter/section/subsection/epigraph/body
translation_with_placeholders: str = "" # 带占位符的译文
translation_with_original_html: str = "" # 还原后的最终 HTML (中文模式)
endnote_anchors: List[str] = field(default_factory=list) # 尾注锚点 ID 列表 (用于补救)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self):
return asdict(self)
class ManifestManager:
"""
负责 Manifest 的生命周期管理。
"""
def __init__(self, manifest_path: str):
self.manifest_path = Path(manifest_path)
self.data: Dict[str, Any] = {
"book_id": "",
"metadata": {},
"chapter_range": {
"start_title": None,
"end_title": None,
"included_files": []
},
"items": []
}
self._items_by_id: Dict[str, ManifestItem] = {}
def load(self) -> bool:
"""从文件加载 Manifest。如果文件不存在则返回 False。"""
if self.manifest_path.exists():
try:
with open(self.manifest_path, 'r', encoding='utf-8') as f:
self.data = json.load(f)
# 重建对象映射
self._items_by_id = {
item['global_id']: ManifestItem(**item)
for item in self.data["items"]
}
logger.info(f"成功从 {self.manifest_path} 加载 Manifest, 包含 {len(self._items_by_id)} 个项目")
return True
except Exception as e:
logger.error(f"加载 Manifest 失败: {e}")
return False
return False
def save(self):
"""将当前状态保存到 Manifest 文件。"""
# 确保目录存在
self.manifest_path.parent.mkdir(parents=True, exist_ok=True)
# 同步 items 到 data 字典
self.data["items"] = [item.to_dict() for item in self._items_by_id.values()]
with open(self.manifest_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=2)
# logger.debug(f"Manifest 已保存到 {self.manifest_path}")
def init_manifest(self, book_id: str, metadata: Dict, chapter_range: Dict = None):
"""初始化一个新的 Manifest。"""
self.data = {
"book_id": book_id,
"metadata": metadata,
"chapter_range": chapter_range or {
"start_title": None,
"end_title": None,
"included_files": []
},
"items": []
}
self._items_by_id = {}
self.save()
def get_chapter_range(self) -> Dict:
"""获取记录的章节范围"""
return self.data.get("chapter_range", {})
def add_item(self, source_file: str, original_html: str, clean_text: str, tag: str, metadata: Dict = None) -> ManifestItem:
"""添加一个新的翻译项并分配 ID。"""
# 生成全局 ID
new_index = len(self._items_by_id) + 1
global_id = f"p_{new_index:05d}"
# 生成内容哈希 (用于排重和缓存)
text_hash = hashlib.sha256(clean_text.encode('utf-8')).hexdigest()
item = ManifestItem(
global_id=global_id,
source_file=source_file,
original_html=original_html,
clean_text=clean_text,
text_hash=text_hash,
tag=tag,
metadata=metadata or {}
)
self._items_by_id[global_id] = item
return item
def get_items(self, status: str = None, file_name: str = None) -> List[ManifestItem]:
"""按状态或文件名查询项目。"""
items = list(self._items_by_id.values())
if status:
items = [i for i in items if i.status == status]
if file_name:
items = [i for i in items if i.source_file == file_name]
# 必须按 ID 顺序返回以保证分块正确
return sorted(items, key=lambda x: x.global_id)
def update_item(self, global_id: str, translation: str, status: str = "translated",
error: str = None, model: str = None, score: int = None,
translation_with_placeholders: str = None,
translation_with_original_html: str = None):
"""更新翻译结果。"""
if global_id in self._items_by_id:
item = self._items_by_id[global_id]
if translation is not None:
item.translation = translation
item.status = status
if error:
item.error_msg = error
if model:
item.model_used = model
if score is not None:
item.quality_score = score
# 中文模式专用字段
if translation_with_placeholders is not None:
item.translation_with_placeholders = translation_with_placeholders
if translation_with_original_html is not None:
item.translation_with_original_html = translation_with_original_html
else:
logger.warning(f"尝试更新不存在的 ID: {global_id}")
@property
def stats(self) -> Dict:
"""获取翻译进度统计。"""
total = len(self._items_by_id)
if total == 0: return {"progress": "0%"}
translated = sum(1 for i in self._items_by_id.values() if i.status == "translated")
ignored = sum(1 for i in self._items_by_id.values() if i.status == "ignored")
failed = sum(1 for i in self._items_by_id.values() if i.status == "failed")
return {
"total": total,
"translated": translated,
"ignored": ignored,
"failed": failed,
"pending": total - translated - ignored - failed,
"progress_percent": round((translated + ignored) / total * 100, 1)
}
+87
View File
@@ -0,0 +1,87 @@
"""
Quality Manager Module
Responsible for evaluating translation quality and deciding on re-translation.
"""
import json
import random
from typing import List, Dict, Any, Tuple
from loguru import logger
from .manifest_manager import ManifestItem
from .llm_client import LLMClient
class QualityManager:
def __init__(self, config: Dict, llm_client: LLMClient):
self.config = config
self.llm_client = llm_client
self.qc_config = config['translation'].get('quality_control', {})
self.pass_score = self.qc_config.get('pass_score', 7)
self.sample_size = self.qc_config.get('sample_size', 2)
async def evaluate_chunk(self, chunk: List[ManifestItem]) -> Tuple[bool, int, str]:
"""
Evaluate a chunk of translations.
Returns:
(passed: bool, average_score: int, reason: str)
"""
if not self.qc_config.get('enabled', False):
return True, 10, "QC Disabled"
# 1. Sample items
# Filter for items that actually have content and translations
valid_items = [item for item in chunk if item.translation and len(item.clean_text) > 20]
if not valid_items:
return True, 10, "No valid items to sample"
sample_items = random.sample(valid_items, min(len(valid_items), self.sample_size))
# 2. Build Prompt
prompt = self._build_evaluation_prompt(sample_items)
# 3. Call LLM (Smart)
try:
response = await self.llm_client.raw_chat_completion(
system_prompt="You are a professional translation editor.",
user_prompt=prompt,
model_type="smart"
)
# 4. Parse JSON
# Clean potential markdown
json_str = response.strip()
if "```json" in json_str:
json_str = json_str.split("```json")[1].split("```")[0].strip()
elif "```" in json_str:
json_str = json_str.split("```")[1].split("```")[0].strip()
result = json.loads(json_str)
score = result.get('score', 0)
reason = result.get('reason', 'No reason provided')
passed = score >= self.pass_score
return passed, score, reason
except Exception as e:
logger.error(f"QC evaluation failed: {e}")
# If QC fails, we default to PASS to avoid blocking progress, but log it
return True, 0, f"QC Error: {e}"
def _build_evaluation_prompt(self, items: List[ManifestItem]) -> str:
content = ""
for i, item in enumerate(items, 1):
content += f"Item {i}:\nOriginal: {item.clean_text}\nTranslation: {item.translation}\n\n"
return f"""Please evaluate the following translations (English to Chinese).
Focus on accuracy, fluency, and terminology consistency.
Items to evaluate:
{content}
Return a JSON object with:
- \"score\": An integer from 1 to 10 (10 being perfect).
- \"reason\": A brief explanation of the score.
JSON Output:"""
+117
View File
@@ -0,0 +1,117 @@
"""
文本处理器模块 (Text Processor Module) - Manifest 驱动版 (集成 V2)
该模块专注于 HTML 文档的遍历和段落提取。
已集成 FineGrainedExtractor,实现稳健的结构提取和格式保护。
"""
import re
from bs4 import BeautifulSoup
from typing import List, Dict, Any
from loguru import logger
from .manifest_manager import ManifestManager
from .fine_grained_extractor import FineGrainedExtractor
class TextProcessor:
"""
负责从 HTML 中识别有效段落并注册到 Manifest。
委托 FineGrainedExtractor 进行具体的提取工作。
"""
def __init__(self, config: Dict):
"""
Args:
config (Dict): 全局配置。
"""
self.config = config
self.chunk_size = config['translation'].get('chunk_size', 5000)
# 不再持有状态,每次调用实例化 Extractor 或复用
def extract_to_manifest(self, html_content: str, source_file: str, manifest: ManifestManager, mode: str = "bilingual"):
"""
解析 HTML 内容,并将识别出的段落注册到 Manifest 中。
Args:
html_content (str): HTML 源码。
source_file (str): 来源文件名。
manifest (ManifestManager): 清单管理器实例。
mode (str): 翻译模式 (保留参数)
"""
try:
# 实例化细粒度提取器 (集成格式保护)
# 是否翻译目录取决于文件名判断,这里交给 Extractor 内部逻辑
# 但 Extractor 构造函数需要参数,默认 False
translate_toc = self.config['translation'].get('translate_toc', False)
extractor = FineGrainedExtractor(translate_toc=translate_toc)
items = extractor.extract(html_content, source_file)
count = 0
for item in items:
# 只注册需要翻译的项
if not item['should_translate']:
continue
clean_text = item.get('text', '')
text_with_ph = item.get('text_with_ph', clean_text)
placeholder_map = item.get('placeholder_map')
# 注册到 Manifest
# original_html 用于记录,但实际回填依靠 FineGrainedExtractor 复原
manifest_item = manifest.add_item(
source_file=source_file,
original_html=str(item['element']),
clean_text=clean_text,
tag=item['tag'],
metadata={"status": "pending"}
)
# 显式设置格式保护字段
manifest_item.text_with_placeholders = text_with_ph
manifest_item.placeholder_map = placeholder_map
manifest_item.endnote_anchors = item.get('endnote_anchors', [])
# 记录段落类型 (从 FormatExtractor 获得的 p_type,目前 FineGrained 没返回,可以改进)
# FineGrained 可以把 FormatExtractor 返回的 p_type 也带出来
# 暂且设为 body,或根据 tag 判断
p_type = "header" if item['tag'].startswith('h') else "body"
manifest_item.paragraph_type = p_type
count += 1
logger.info(f"提取完成 {source_file}: 注册 {count} 个待翻译项")
except Exception as e:
logger.error(f"{source_file} 提取段落失败: {e}", exc_info=True)
def create_chunks_from_manifest(self, manifest: ManifestManager, mode: str = "bilingual") -> List[List[Any]]:
"""
从 Manifest 中筛选待翻译项目并分块。
(保留原有逻辑)
"""
pending_items = manifest.get_items(status="pending")
if not pending_items:
return []
chunks = []
current_chunk = []
current_size = 0
for item in pending_items:
# 优先使用带占位符的文本长度计算
text_len = len(item.text_with_placeholders) if item.text_with_placeholders else len(item.clean_text)
if current_size + text_len > self.chunk_size and current_chunk:
chunks.append(current_chunk)
current_chunk = []
current_size = 0
current_chunk.append(item)
current_size += text_len
if current_chunk:
chunks.append(current_chunk)
logger.info(f"分块完成: 共有 {len(pending_items)} 个待翻译项,分为 {len(chunks)} 个块")
return chunks
+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)
+348
View File
@@ -0,0 +1,348 @@
"""
EPUB Translator Core Module - v0.09 (TOC Selection Support)
"""
import asyncio
import traceback
from typing import List, Dict, Any
from pathlib import Path
from loguru import logger
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
from .epub_parser import EPUBParser
from .toc_parser import TOCParser
from .llm_client import LLMClient
from .text_processor import TextProcessor
from .bilingual_builder import BilingualEPUBBuilder
from .chinese_builder import ChineseEPUBBuilder
from .manifest_manager import ManifestManager
from .book_profiler import BookProfiler
from .cache import TranslationCache
from .format_restorer import FormatRestorer
from .utils import add_spacing_between_cn_and_en_num
class EPUBTranslator:
def __init__(self, config: Dict, use_cache: bool = True):
self.config = config
self.console = Console()
self.use_cache = use_cache
self.parser = None
self.llm_client = LLMClient(config)
self.text_processor = TextProcessor(config)
self.profiler = BookProfiler(config, self.llm_client)
self.cache = TranslationCache(config) if use_cache else None
self.restorer = FormatRestorer()
self.manifest_dir = Path("cache/manifests")
self.manifest_dir.mkdir(parents=True, exist_ok=True)
async def translate_epub(self, epub_path: str, test_mode: bool = False,
output_dir: str = None, mode: str = "bilingual",
from_chapter: str = None, to_chapter: str = None) -> str:
"""
翻译 EPUB 文件
"""
try:
epub_path = Path(epub_path)
# --- 0. Preprocessing (集成的 EpubCleaner) ---
# 创建临时清理文件
from .epub_cleaner import EpubCleaner
cleaner = EpubCleaner()
processed_dir = self.manifest_dir / "processed_epubs"
processed_dir.mkdir(parents=True, exist_ok=True)
cleaned_epub_path = processed_dir / f"{epub_path.stem}_cleaned.epub"
self.console.print(f"[yellow]Preprocessing: Cleaning EPUB structures...[/yellow]")
cleaner.clean_epub(str(epub_path), str(cleaned_epub_path))
# 关键:后续操作全都基于清理后的 EPUB
# 注意:这会改变 source_file 的上下文吗?只要 parser 读的是 cleaned_epubmanifest 记录的就是 cleaned_epub 里的文件名。
# 而 Builder 用 cleaned_epub 初始化的,所以也是匹配的。
actual_epub_path = cleaned_epub_path
self.parser = EPUBParser(str(actual_epub_path))
# 0.5 解析 TOC 并处理章节范围
toc_parser = TOCParser(self.parser.book)
include_files = None
chapter_range_info = None
if from_chapter or to_chapter:
include_files, selected_items = toc_parser.get_spine_range(
start_title=from_chapter, end_title=to_chapter
)
if selected_items:
start_title = selected_items[0].title
end_title = selected_items[-1].title
self.console.print(f"[cyan]📚 Range: {start_title} ~ {end_title} ({len(include_files)} files)[/cyan]")
chapter_range_info = {"start_title": start_title, "end_title": end_title, "included_files": list(include_files)}
else:
skip_files = toc_parser.get_skip_files()
if skip_files:
include_files = toc_parser.get_content_files_from_spine()
self.console.print(f"[cyan]📚 Smart Skip: {len(skip_files)} non-content files[/cyan]")
# 1. Manifest
manifest_suffix = "_chinese" if mode == "chinese" else ""
manifest_path = self.manifest_dir / f"{epub_path.stem}{manifest_suffix}_manifest.json"
manifest = ManifestManager(str(manifest_path))
if not manifest.load() or not self.use_cache:
self.console.print(f"[yellow]Initializing Manifest...[/yellow]")
manifest.init_manifest(book_id=epub_path.name, metadata=self.parser.get_book_info(), chapter_range=chapter_range_info)
content_items = self.parser.extract_all_content_items(include_files=include_files)
for item in content_items:
self.text_processor.extract_to_manifest(item['content'], item['file_name'], manifest, mode=mode)
manifest.save()
# (Stats logic...)
stats = manifest.stats
self.console.print(f"[green]Manifest: {stats['total']} items ({stats['pending']} pending)[/green]")
# 2. Profile
profile = {}
if not test_mode and stats['pending'] > 0:
self.console.print("[yellow]Profiling Book...[/yellow]")
profile = await self.profiler.analyze_book(manifest)
# 3. Translate
chunks = self.text_processor.create_chunks_from_manifest(manifest, mode=mode)
if test_mode: chunks = chunks[:10]
if chunks:
await self._translate_concurrently(chunks, manifest, profile, mode=mode)
# 4. Build
self.console.print(f"\n[yellow]Building {mode} EPUB...[/yellow]")
output_path = output_dir or self.config['output']['output_dir']
# 统一使用 BilingualEPUBBuilder (集成 V2)
# 因为它已经支持了 FineGrained backfill,可以处理 bilingual 参数
# 但目前 builder 还没暴露 bilingual 参数给 create 方法?
# 我们可以简单地在 builder.create... 里改一下,或者总是用 BilingualBuilder。
# 用户想要 "chinese" mode (replace).
# BilingualBuilder.create... 目前 hardcode 了 bilingual=True (TODO comment in previous step).
# 我们应该让 BilingualBuilder 支持 mode 参数。
# 为了简单,我假设 builder 内部会处理,或者我之后微调 builder。
# 修改: BilingualEPUBBuilder 是通用的 backfiller。
# 构建 Mapping
if mode == "chinese":
# 中文模式:使用还原后的 HTML (保留格式)
translation_map = {
item.global_id: (item.translation_with_original_html or item.translation)
for item in manifest.get_items()
if item.translation_with_original_html or item.translation
}
else:
# 双语模式:使用纯文本
translation_map = {item.global_id: item.translation for item in manifest.get_items() if item.translation}
paragraph_map = {item.global_id: {
"file_name": item.source_file,
# 其他 metadata 其实不需要了,builder 会重新提取
} for item in manifest.get_items()}
builder = BilingualEPUBBuilder(self.parser.book, self.config)
# 临时 Hack: 如果是 chinese 模式,修改 builder 的逻辑 (或者 builder 自动读取 config)
# Builder 构造函数读了 config。
# 我们需要在 config 里设置 mode 吗?或者 Builder 可以加个 set_mode?
# 这里的 config 是全局 config。main.py 里并没有把 args.mode 写入 config['output']。
# 我们可以在这里 patch 一下 config。
self.config['output']['mode'] = mode # 确保 Builder 知道模式
# 注意: 之前的 BilingualBuilder._process_document_content 里写死 bilingual_mode = True
# 我需要去修一下 BilingualBuilder,让它读 self.config['output']['mode']
result_file = builder.create_bilingual_epub_with_mapping(translation_map, paragraph_map, output_path)
self.console.print(f"[green]Done: {result_file}[/green]")
return result_file
except Exception as e:
traceback.print_exc()
logger.error(f"Translation failed: {e}")
raise
async def _translate_concurrently(self, chunks: List[List[Any]], manifest: ManifestManager,
profile: Dict, mode: str = "bilingual"):
"""并发翻译核心逻辑 (统一单双语)"""
total_chunks = len(chunks)
glossary = profile.get('glossary', {})
instruction = profile.get('translation_instruction', "")
with Progress(
SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
BarColumn(), TextColumn("{task.percentage:>3.0f}%"), TimeElapsedColumn(),
console=self.console
) as progress:
task_id = progress.add_task(f"[cyan]Translating...", total=total_chunks)
async def worker(chunk, idx):
try:
chunk_dicts = [item.to_dict() for item in chunk]
results = None
model_name = self.llm_client.models.get('fast', 'unknown')
if self.cache:
results = self.cache.get_chunk_translation(chunk_dicts, model=model_name)
if not results:
# FIX: Use keyword arguments to avoid positional mismatch (model_type vs mode)
results = await self.llm_client.translate_chunk(
items=chunk,
glossary=glossary,
instruction=instruction,
mode=mode
)
if self.cache and results:
self.cache.save_chunk_translation(chunk_dicts, results, model=model_name)
for item in chunk:
if item.global_id in results:
raw_trans = results[item.global_id]
if "[Error" in raw_trans:
manifest.update_item(item.global_id, None, status="failed", error=raw_trans)
continue
processed_trans = add_spacing_between_cn_and_en_num(raw_trans)
# 仅保存译文,还原逻辑外移至所有翻译完成后执行
manifest.update_item(
item.global_id,
processed_trans,
translation_with_placeholders=processed_trans,
status="translated"
)
else:
manifest.update_item(item.global_id, None, status="failed", error="Translate failed: ID not found in response")
except Exception as e:
logger.error(f"Worker {idx} error: {e}")
finally:
progress.update(task_id, advance=1)
# 并发执行翻译任务
tasks = [worker(chunk, i) for i, chunk in enumerate(chunks)]
await asyncio.gather(*tasks)
# 翻译完成后立即保存,确保数据持久化
manifest.save()
logger.info("翻译阶段完成,manifest 已保存")
# --- 第二阶段:统一进行格式还原与修复 ---
logger.info("开始进行格式还原与占位符校验...")
await self.process_format_restoration(manifest, mode)
async def process_format_restoration(self, manifest, mode):
"""统一处理所有段落的格式还原和修复"""
items = manifest.get_items()
success_count = 0
failed_count = 0
repaired_count = 0
for item in items:
# 仅处理已翻译或之前格式还原失败的项目,或者已完成但缺少还原HTML的项目
should_process = (
item.status == "translated" or
item.status == "format_error" or
(item.status == "completed" and not item.translation_with_original_html)
)
if not should_process or not item.translation_with_placeholders:
continue
processed_trans = item.translation_with_placeholders
translation_with_ph = processed_trans
restored_html = ""
success = False
if item.placeholder_map:
# 过滤内嵌占位符(排除 _prefix, _suffix
inner_placeholders = {k: v for k, v in item.placeholder_map.items()
if not k.startswith("_")}
if not inner_placeholders:
# 没有内嵌占位符,清除可能多出的占位符
clean_translation = self.restorer._strip_placeholders(processed_trans)
translation_with_ph = clean_translation
restored_html, success = self.restorer.restore(clean_translation, item.placeholder_map)
else:
# 有内嵌占位符,尝试直接还原
restored_html, success = self.restorer.restore(processed_trans, item.placeholder_map)
if not success:
# 尝试修复逻辑
import re
found_ids = set(re.findall(r'φ(/?\d+)φ', processed_trans))
expected_ids = set(inner_placeholders.keys())
missing_ids = expected_ids - found_ids
if missing_ids:
logger.warning(f"占位符缺失 (ID: {item.global_id}), 尝试修复: {missing_ids}")
try:
fixed_trans = await self.llm_client.repair_format(
item.text_with_placeholders,
processed_trans,
missing_ids=missing_ids
)
restored_html_2, success_2 = self.restorer.restore(fixed_trans, item.placeholder_map)
if success_2:
repaired_count += 1
translation_with_ph = fixed_trans
restored_html = restored_html_2
success = True
else:
# 尾注补救
if hasattr(item, 'endnote_anchors') and item.endnote_anchors:
still_missing = [a for a in item.endnote_anchors
if f"φ{a}φ" not in fixed_trans]
if still_missing:
for anchor_id in still_missing:
fixed_trans += f"φ{anchor_id}φ"
restored_html_3, success_3 = self.restorer.restore(fixed_trans, item.placeholder_map)
if success_3:
translation_with_ph = fixed_trans
restored_html = restored_html_3
success = True
except Exception as e:
logger.error(f"修复失败 (ID: {item.global_id}): {e}")
else:
# 无需还原
restored_html = processed_trans
success = True
# 更新 Manifest
final_translation = self.restorer._strip_placeholders(translation_with_ph)
manifest.update_item(
item.global_id,
final_translation,
translation_with_placeholders=translation_with_ph,
translation_with_original_html=restored_html,
status="completed" if success else "format_error"
)
if success: success_count += 1
else: failed_count += 1
# 保存还原结果
manifest.save()
# 统计错误比例
total_processed = success_count + failed_count
if total_processed > 0:
error_rate = failed_count / total_processed
logger.info(f"占位符还原完成: 成功 {success_count}, 失败 {failed_count}, 修复 {repaired_count}, 错误率 {error_rate*100:.2f}%")
# 超过 1% 错误率,判定为严重问题,中止操作
if error_rate > 0.01:
error_msg = f"占位符错误率过高 ({error_rate*100:.2f}% > 1%),检测到严重不可修复问题,中止操作"
logger.error(error_msg)
raise RuntimeError(error_msg)
else:
logger.warning("没有处理任何段落")
+208
View File
@@ -0,0 +1,208 @@
"""
工具函数模块
提供配置加载、日志设置等通用功能
"""
import json
import os
from pathlib import Path
from typing import Dict, Any
from loguru import logger
import sys
from dotenv import load_dotenv
def load_config(config_path: str = "config/config.json") -> Dict[str, Any]:
"""
加载配置文件
Args:
config_path: 配置文件路径
Returns:
配置字典
"""
# 加载 .env 文件
load_dotenv()
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# 从环境变量获取 API Key
for provider_name, provider_config in config.get('providers', {}).items():
env_key = f"{provider_name.upper()}_API_KEY"
if env_key in os.environ:
provider_config['api_key'] = os.environ[env_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] + "..."
def add_spacing_between_cn_and_en_num(text: str) -> str:
"""
在中文和英文/数字之间添加空格(盘古之白)
Args:
text: 原始文本
Returns:
处理后的文本
"""
import re
if not text:
return text
# 中文-英文/数字
text = re.sub(r'([\u4e00-\u9fff])([a-zA-Z0-9])', r'\1 \2', text)
# 英文/数字-中文
text = re.sub(r'([a-zA-Z0-9])([\u4e00-\u9fff])', r'\1 \2', text)
return text