feat: Release v0.10 - Modular Architecture & External Config
- Refactor codebase into src/ (preprocessing, translation, assembly) - Add pipeline/ scripts for individual stages - Externalize configuration to config/config.yaml - Fix Cover Image preservation - Update documentation and manuals
This commit is contained in:
@@ -0,0 +1,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"
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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 {}
|
||||
@@ -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)}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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_html,FormatExtractor 解析时只能拿到第一个子节点,导致内容丢失验证失败
|
||||
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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:"""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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_epub,manifest 记录的就是 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("没有处理任何段落")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user