Initial commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
EPUB 双语翻译程序
|
||||
主要功能模块的初始化文件
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Kaitan"
|
||||
|
||||
from .epub_parser import EPUBParser
|
||||
from .translator import EPUBTranslator
|
||||
from .llm_client import OpenRouterClient
|
||||
from .text_processor import TextProcessor
|
||||
from .bilingual_builder import BilingualEPUBBuilder
|
||||
from .utils import load_config, setup_logging
|
||||
|
||||
__all__ = [
|
||||
"EPUBParser",
|
||||
"EPUBTranslator",
|
||||
"OpenRouterClient",
|
||||
"TextProcessor",
|
||||
"BilingualEPUBBuilder",
|
||||
"load_config",
|
||||
"setup_logging"
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
双语 EPUB 构建器模块 - 安全的EPUB构建 (Manifest 兼容版)
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class BilingualEPUBBuilder:
|
||||
"""双语 EPUB 构建器"""
|
||||
|
||||
def __init__(self, original_book, config: Dict):
|
||||
self.original_book = original_book
|
||||
self.config = config
|
||||
self.output_config = config['output']
|
||||
|
||||
def create_bilingual_epub_with_mapping(self, translation_map: Dict[str, str],
|
||||
paragraph_map: Dict[str, Dict],
|
||||
output_path: str) -> str:
|
||||
"""
|
||||
创建双语 EPUB。使用 ordered_ids 确保与 Manifest 严格一致。
|
||||
"""
|
||||
try:
|
||||
new_book = epub.EpubBook()
|
||||
self._copy_metadata(new_book)
|
||||
new_book.toc = self.original_book.toc
|
||||
|
||||
# 准备每个文件的有序ID列表
|
||||
file_ordered_ids = {}
|
||||
sorted_pids = sorted(paragraph_map.keys(), key=lambda x: int(x.split('_')[1]))
|
||||
for pid in sorted_pids:
|
||||
info = paragraph_map[pid]
|
||||
fname = info['file_name']
|
||||
if fname not in file_ordered_ids:
|
||||
file_ordered_ids[fname] = []
|
||||
file_ordered_ids[fname].append(pid)
|
||||
|
||||
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_ordered_ids:
|
||||
new_item = self._create_bilingual_document(
|
||||
item, file_ordered_ids[file_name], translation_map
|
||||
)
|
||||
new_item.id = item.id
|
||||
else:
|
||||
new_item = item
|
||||
|
||||
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 _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"bilingual-{uuid.uuid4().hex[:12]}")
|
||||
|
||||
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)
|
||||
new_book.set_cover(cover_item.get_name(), cover_item.get_content())
|
||||
except Exception as e:
|
||||
logger.error(f"元数据复制出错: {e}")
|
||||
|
||||
def _create_bilingual_document(self, original_item, ordered_ids: list, translation_map: dict):
|
||||
try:
|
||||
from .text_processor import TextProcessor
|
||||
soup = BeautifulSoup(original_item.get_content().decode('utf-8'), 'html.parser')
|
||||
self._add_style_link(soup)
|
||||
|
||||
# 使用与 TextProcessor 相同的过滤逻辑获取元素
|
||||
text_elements = TextProcessor.get_valid_text_elements(soup)
|
||||
|
||||
current_para_index = 0
|
||||
for element in text_elements:
|
||||
if TextProcessor.is_navigation_element(element): continue
|
||||
if not TextProcessor.clean_element_text(element): continue
|
||||
|
||||
if current_para_index < len(ordered_ids):
|
||||
target_id = ordered_ids[current_para_index]
|
||||
translation = translation_map.get(target_id)
|
||||
if translation:
|
||||
self._insert_translation(element, translation, soup)
|
||||
current_para_index += 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 _add_style_link(self, soup):
|
||||
head = soup.find('head')
|
||||
if head and not head.find('link', href='style/bilingual.css'):
|
||||
head.append(soup.new_tag('link', rel='stylesheet', type='text/css', href='style/bilingual.css'))
|
||||
|
||||
def _insert_translation(self, element, translation: str, soup):
|
||||
try:
|
||||
translation_p = soup.new_tag('p')
|
||||
translation_p.string = translation
|
||||
translation_p['class'] = ['translation-text', 'chinese']
|
||||
element.insert_after(translation_p)
|
||||
except: pass
|
||||
|
||||
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 "bilingual_book"
|
||||
Path(output_path).mkdir(parents=True, exist_ok=True)
|
||||
return str(Path(output_path) / f"{clean_title}_bilingual.epub")
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
翻译缓存管理模块 - 简化版
|
||||
基于全局ID和chunk的缓存系统
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, List
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TranslationCache:
|
||||
"""翻译缓存管理器 - 简化版"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""初始化缓存管理器"""
|
||||
self.config = config
|
||||
cache_config = config.get('cache', {})
|
||||
|
||||
self.enabled = cache_config.get('enabled', True)
|
||||
self.cache_dir = Path(cache_config.get('directory', 'cache'))
|
||||
self.max_age_days = cache_config.get('max_age_days', 30)
|
||||
|
||||
if self.enabled:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.translations_dir = self.cache_dir / 'translations'
|
||||
self.translations_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"翻译缓存已启用: {self.cache_dir}")
|
||||
|
||||
def get_chunk_translation(self, chunk: List[Dict], model: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
获取chunk的缓存翻译
|
||||
|
||||
Args:
|
||||
chunk: 段落列表(带global_id)
|
||||
model: 模型名称
|
||||
|
||||
Returns:
|
||||
{global_id: translation} 映射,如果不存在返回 None
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
cache_key = self._get_chunk_cache_key(chunk, model)
|
||||
cache_file = self._get_cache_file_path(cache_key)
|
||||
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
# 检查是否过期
|
||||
file_age = datetime.now() - datetime.fromtimestamp(cache_file.stat().st_mtime)
|
||||
if file_age > timedelta(days=self.max_age_days):
|
||||
logger.debug(f"缓存已过期: {cache_key[:8]}...")
|
||||
cache_file.unlink()
|
||||
return None
|
||||
|
||||
# 读取缓存
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# 验证缓存
|
||||
if (cache_data.get('success') and
|
||||
cache_data.get('model') == model and
|
||||
self._validate_cache_data(cache_data, chunk)):
|
||||
|
||||
logger.debug(f"缓存命中: {cache_key[:8]}... ({len(chunk)} 段落)")
|
||||
return cache_data.get('translations', {})
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"读取缓存失败: {e}")
|
||||
return None
|
||||
|
||||
def save_chunk_translation(self, chunk: List[Dict], translations: Dict[str, str],
|
||||
model: str, success: bool = True) -> None:
|
||||
"""
|
||||
保存chunk翻译到缓存
|
||||
|
||||
Args:
|
||||
chunk: 段落列表(带global_id)
|
||||
translations: {global_id: translation} 映射
|
||||
model: 模型名称
|
||||
success: 是否翻译成功
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
cache_key = self._get_chunk_cache_key(chunk, model)
|
||||
cache_file = self._get_cache_file_path(cache_key)
|
||||
|
||||
# 构建缓存数据
|
||||
cache_data = {
|
||||
'global_ids': [p['global_id'] for p in chunk],
|
||||
'translations': translations,
|
||||
'model': model,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'success': success,
|
||||
'paragraph_count': len(chunk),
|
||||
'cache_version': '3.0'
|
||||
}
|
||||
|
||||
with open(cache_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.debug(f"缓存已保存: {cache_key[:8]}... ({len(chunk)} 段落)")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"保存缓存失败: {e}")
|
||||
|
||||
def _get_chunk_cache_key(self, chunk: List[Dict], model: str) -> str:
|
||||
"""
|
||||
生成chunk缓存键(基于全局ID序列)
|
||||
|
||||
Args:
|
||||
chunk: 段落列表
|
||||
model: 模型名称
|
||||
|
||||
Returns:
|
||||
缓存键
|
||||
"""
|
||||
# 使用全局ID序列作为缓存键的一部分
|
||||
id_sequence = ",".join(p['global_id'] for p in chunk)
|
||||
combined = f"{id_sequence}|{model}"
|
||||
return hashlib.md5(combined.encode('utf-8')).hexdigest()
|
||||
|
||||
def _get_cache_file_path(self, cache_key: str) -> Path:
|
||||
"""获取缓存文件路径"""
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
cache_date_dir = self.translations_dir / today
|
||||
cache_date_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_date_dir / f"{cache_key}.json"
|
||||
|
||||
def _validate_cache_data(self, cache_data: Dict, chunk: List[Dict]) -> bool:
|
||||
"""验证缓存数据的有效性"""
|
||||
# 检查ID序列是否匹配
|
||||
cached_ids = cache_data.get('global_ids', [])
|
||||
chunk_ids = [p['global_id'] for p in chunk]
|
||||
|
||||
if cached_ids != chunk_ids:
|
||||
logger.debug("缓存ID序列不匹配")
|
||||
return False
|
||||
|
||||
# 检查翻译数量
|
||||
translations = cache_data.get('translations', {})
|
||||
if len(translations) != len(chunk):
|
||||
logger.debug("缓存翻译数量不匹配")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def clear_cache(self, older_than_days: Optional[int] = None) -> int:
|
||||
"""清理缓存"""
|
||||
if not self.enabled or not self.translations_dir.exists():
|
||||
return 0
|
||||
|
||||
cleared_count = 0
|
||||
cutoff_time = None
|
||||
|
||||
if older_than_days is not None:
|
||||
cutoff_time = datetime.now() - timedelta(days=older_than_days)
|
||||
|
||||
try:
|
||||
for cache_file in self.translations_dir.rglob('*.json'):
|
||||
should_delete = False
|
||||
|
||||
if cutoff_time is None:
|
||||
should_delete = True
|
||||
else:
|
||||
file_time = datetime.fromtimestamp(cache_file.stat().st_mtime)
|
||||
should_delete = file_time < cutoff_time
|
||||
|
||||
if should_delete:
|
||||
cache_file.unlink()
|
||||
cleared_count += 1
|
||||
|
||||
# 清理空目录
|
||||
for date_dir in self.translations_dir.iterdir():
|
||||
if date_dir.is_dir() and not any(date_dir.iterdir()):
|
||||
date_dir.rmdir()
|
||||
|
||||
logger.info(f"清理了 {cleared_count} 个缓存文件")
|
||||
return cleared_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理缓存失败: {e}")
|
||||
return 0
|
||||
|
||||
def get_cache_stats(self) -> Dict:
|
||||
"""获取缓存统计信息"""
|
||||
if not self.enabled or not self.translations_dir.exists():
|
||||
return {'enabled': False}
|
||||
|
||||
try:
|
||||
cache_files = list(self.translations_dir.rglob('*.json'))
|
||||
total_files = len(cache_files)
|
||||
total_size = sum(f.stat().st_size for f in cache_files)
|
||||
|
||||
# 统计段落数
|
||||
total_paragraphs = 0
|
||||
for cache_file in cache_files:
|
||||
try:
|
||||
with open(cache_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
total_paragraphs += data.get('paragraph_count', 0)
|
||||
except:
|
||||
continue
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'total_files': total_files,
|
||||
'total_paragraphs': total_paragraphs,
|
||||
'total_size_mb': round(total_size / 1024 / 1024, 2),
|
||||
'cache_directory': str(self.cache_dir),
|
||||
'max_age_days': self.max_age_days
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取缓存统计失败: {e}")
|
||||
return {'enabled': True, 'error': str(e)}
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
EPUB 解析器模块 (EPUB Parser Module)
|
||||
|
||||
该模块负责读取 EPUB 文件,提取元数据和内容项目。
|
||||
它使用 ebooklib 库来处理 EPUB 格式的底层细节。
|
||||
|
||||
Classes:
|
||||
EPUBParser: 负责 EPUB 文件的加载、元数据提取和内容项遍历。
|
||||
"""
|
||||
|
||||
import ebooklib
|
||||
from ebooklib import epub
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Dict, Any
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class EPUBParser:
|
||||
"""
|
||||
EPUB 文件解析器。
|
||||
|
||||
负责加载 EPUB 文件,提取书籍元数据(如标题、作者),并提供方法来遍历和提取
|
||||
书中的文档内容(HTML/XHTML)。
|
||||
|
||||
Attributes:
|
||||
epub_path (Path): EPUB 文件的路径对象。
|
||||
book (epub.EpubBook): ebooklib 加载的书籍对象。
|
||||
metadata (Dict[str, str]): 提取的书籍元数据字典。
|
||||
"""
|
||||
|
||||
def __init__(self, epub_path: str):
|
||||
"""
|
||||
初始化 EPUB 解析器。
|
||||
|
||||
Args:
|
||||
epub_path (str): EPUB 文件的文件路径。
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 如果指定的文件不存在。
|
||||
Exception: 如果 EPUB 文件加载失败(格式错误等)。
|
||||
"""
|
||||
self.epub_path = Path(epub_path)
|
||||
if not self.epub_path.exists():
|
||||
raise FileNotFoundError(f"EPUB 文件不存在: {epub_path}")
|
||||
|
||||
try:
|
||||
# ignore_ncx=True 是为了避免某些旧版 epub 的警告,但新版 ebooklib 可能行为不同
|
||||
# 这里直接读取,让 ebooklib 处理
|
||||
self.book = epub.read_epub(str(self.epub_path))
|
||||
logger.info(f"成功加载 EPUB: {self.epub_path.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"加载 EPUB 失败: {e}")
|
||||
raise
|
||||
|
||||
self.metadata = self._extract_metadata()
|
||||
|
||||
def _extract_metadata(self) -> Dict[str, str]:
|
||||
"""
|
||||
从 EPUB 对象中提取标准元数据。
|
||||
|
||||
提取 Dublin Core (DC) 元数据,包括标题、作者和语言。
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 包含 'title', 'author', 'language' 的字典。
|
||||
如果提取失败,会使用默认值 ("Unknown", "en")。
|
||||
"""
|
||||
metadata = {}
|
||||
|
||||
try:
|
||||
# get_metadata 返回的是 (value, dict) 的列表,我们取第一个结果
|
||||
title_meta = self.book.get_metadata('DC', 'title')
|
||||
metadata['title'] = title_meta[0][0] if title_meta else "Unknown"
|
||||
|
||||
author_meta = self.book.get_metadata('DC', 'creator')
|
||||
metadata['author'] = author_meta[0][0] if author_meta else "Unknown"
|
||||
|
||||
lang_meta = self.book.get_metadata('DC', 'language')
|
||||
metadata['language'] = lang_meta[0][0] if lang_meta else "en"
|
||||
|
||||
logger.info(f"书籍: {metadata['title']} - {metadata['author']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"提取元数据时出错: {e}")
|
||||
# 设置保底值
|
||||
metadata.setdefault('title', 'Unknown')
|
||||
metadata.setdefault('author', 'Unknown')
|
||||
metadata.setdefault('language', 'en')
|
||||
|
||||
return metadata
|
||||
|
||||
def extract_all_content_items(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
提取所有可翻译的内容项目(文档)。
|
||||
|
||||
遍历 EPUB 中的所有 Item,筛选出类型为 ITEM_DOCUMENT 的项目。
|
||||
同时会进行简单的过滤,跳过内容过短(<100字符)或看起来像非正文的文件(如 nav, toc, cover)。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 内容项目列表。每个字典包含:
|
||||
- item (epub.EpubItem): 原始 Item 对象。
|
||||
- file_name (str): 文件名。
|
||||
- content (str): 解码后的 HTML 内容。
|
||||
- text_length (int): 纯文本长度(用于统计)。
|
||||
"""
|
||||
content_items = []
|
||||
|
||||
# 获取所有文档类型的项目
|
||||
for item in self.book.get_items():
|
||||
if item.get_type() == ebooklib.ITEM_DOCUMENT:
|
||||
try:
|
||||
# 获取内容 (bytes -> str)
|
||||
content = item.get_content().decode('utf-8')
|
||||
|
||||
# 简单的内容验证:提取纯文本检查长度
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
text = soup.get_text().strip()
|
||||
|
||||
# 1. 跳过太短的内容(可能是只有图片的页面、空页面)
|
||||
if len(text) < 100:
|
||||
logger.debug(f"跳过短内容: {item.get_name()} ({len(text)} 字符)")
|
||||
continue
|
||||
|
||||
# 2. 跳过明显的非正文内容 (根据文件名判断)
|
||||
name_lower = item.get_name().lower()
|
||||
skip_patterns = ['cover', 'copyright', 'titlepage', 'halftitle',
|
||||
'nav.xhtml', 'toc.xhtml']
|
||||
if any(pattern in name_lower for pattern in skip_patterns):
|
||||
logger.debug(f"跳过非正文内容: {item.get_name()}")
|
||||
continue
|
||||
|
||||
content_items.append({
|
||||
'item': item,
|
||||
'file_name': item.get_name(),
|
||||
'content': content,
|
||||
'text_length': len(text)
|
||||
})
|
||||
|
||||
logger.debug(f"添加内容项: {item.get_name()} ({len(text)} 字符)")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"处理项目失败 {item.get_name()}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"提取了 {len(content_items)} 个内容项目")
|
||||
return content_items
|
||||
|
||||
def get_book_info(self) -> Dict[str, str]:
|
||||
"""
|
||||
获取书籍的摘要信息。
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 包含文件名、标题、作者、语言和文档数量的字典。
|
||||
"""
|
||||
# 统计内容项
|
||||
document_count = sum(1 for item in self.book.get_items()
|
||||
if item.get_type() == ebooklib.ITEM_DOCUMENT)
|
||||
|
||||
return {
|
||||
'filename': self.epub_path.name,
|
||||
'title': self.metadata.get('title', 'Unknown'),
|
||||
'author': self.metadata.get('author', 'Unknown'),
|
||||
'language': self.metadata.get('language', 'en'),
|
||||
'document_count': document_count
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
LLM 客户端模块 (LLM Client Module) - 简化 ID 锚点匹配版
|
||||
|
||||
该模块负责发送翻译请求,并使用简化后的 ID 作为锚点解析 LLM 的响应。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from openai import AsyncOpenAI
|
||||
from typing import List, Dict, Optional, Any
|
||||
from loguru import logger
|
||||
import time
|
||||
import re
|
||||
from .manifest_manager import ManifestItem
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""并发与 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
|
||||
|
||||
async def acquire(self):
|
||||
await self.semaphore.acquire()
|
||||
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 OpenRouterClient:
|
||||
"""基于简化 ID 锚点匹配逻辑的 LLM 客户端。"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
or_config = config["openrouter"]
|
||||
api_key = or_config.get("api_key")
|
||||
if not api_key or api_key == "YOUR_OPENROUTER_API_KEY":
|
||||
raise ValueError("请设置有效的 OpenRouter API Key")
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
base_url=or_config["base_url"],
|
||||
api_key=api_key,
|
||||
default_headers={"HTTP-Referer": "https://github.com/epub-translator", "X-Title": "EPUB Translator"}
|
||||
)
|
||||
self.models = or_config["models"]
|
||||
self.rate_limiter = RateLimiter(
|
||||
or_config["rate_limits"]["requests_per_minute"],
|
||||
or_config["rate_limits"]["concurrent_requests"]
|
||||
)
|
||||
|
||||
async def translate_chunk(self, items: List[ManifestItem], model_type: str = "production") -> Dict[str, str]:
|
||||
"""
|
||||
翻译一个段落块。
|
||||
"""
|
||||
if not items: return {}
|
||||
|
||||
prompt = self._build_prompt(items)
|
||||
model = self.models.get(model_type, self.models["production"])
|
||||
|
||||
try:
|
||||
raw_response = await self._make_request(prompt, model)
|
||||
if not raw_response:
|
||||
return {item.global_id: f"[翻译失败 - API空响应]" for item in items}
|
||||
|
||||
return self._parse_with_anchors(raw_response, items)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"翻译请求异常: {e}")
|
||||
return {item.global_id: f"[翻译失败 - {str(e)}]" for item in items}
|
||||
|
||||
def _build_prompt(self, items: List[ManifestItem]) -> str:
|
||||
lines = ["请将以下编号的英文段落翻译成中文。每段翻译前必须带上原编号,格式为 p_xxxxx。", "原文:", ""]
|
||||
for item in items:
|
||||
lines.append(f"{item.global_id} {item.clean_text}")
|
||||
lines.extend(["", "要求:只返回翻译,不要解释。必须保留原编号。"])
|
||||
return "\n".join(lines)
|
||||
|
||||
def _parse_with_anchors(self, response: str, items: List[ManifestItem]) -> Dict[str, str]:
|
||||
results = {}
|
||||
|
||||
for i, item in enumerate(items):
|
||||
current_id = item.global_id
|
||||
|
||||
# 使用简单的字符串拼接构造正则
|
||||
start_pat = r"\b" + current_id + r"\b"
|
||||
start_match = re.search(start_pat, response)
|
||||
if not start_match:
|
||||
continue
|
||||
|
||||
start_pos = start_match.start()
|
||||
end_pos = len(response)
|
||||
|
||||
if i + 1 < len(items):
|
||||
next_id = items[i+1].global_id
|
||||
next_pat = r"\b" + next_id + r"\b"
|
||||
next_match = re.search(next_pat, response[start_pos + 1:])
|
||||
if next_match:
|
||||
end_pos = start_pos + 1 + next_match.start()
|
||||
|
||||
segment = response[start_pos:end_pos].strip()
|
||||
|
||||
# 清洗 ID
|
||||
# 注意:这里的正则使用了更稳健的拼接方式
|
||||
id_clean_pat = r"^(\[?" + current_id + r"\]?[::\s]*)+"
|
||||
cleaned = re.sub(id_clean_pat, "", segment).strip()
|
||||
|
||||
if cleaned:
|
||||
results[current_id] = cleaned
|
||||
|
||||
if len(results) < len(items):
|
||||
expected_ids = {item.global_id for item in items}
|
||||
missing_ids = expected_ids - set(results.keys())
|
||||
logger.warning(f"锚点匹配缺失 {len(missing_ids)} 个。尝试降级解析...")
|
||||
fallback_results = self._fallback_parse(response, items)
|
||||
for pid, trans in fallback_results.items():
|
||||
if pid not in results:
|
||||
results[pid] = trans
|
||||
|
||||
return results
|
||||
|
||||
def _fallback_parse(self, response: str, items: List[ManifestItem]) -> Dict[str, str]:
|
||||
results = {}
|
||||
lines = [l.strip() for l in response.split("\n") if l.strip()]
|
||||
for line in lines:
|
||||
for item in items:
|
||||
if item.global_id in line:
|
||||
# 同样的稳健拼接
|
||||
fallback_clean_pat = r"^\(?" + item.global_id + r"\)?[::\s]*"
|
||||
clean_line = re.sub(fallback_clean_pat, "", line).strip()
|
||||
if clean_line:
|
||||
results[item.global_id] = clean_line
|
||||
return results
|
||||
|
||||
async def _make_request(self, prompt: str, model: str) -> str:
|
||||
await self.rate_limiter.acquire()
|
||||
try:
|
||||
resp = await self.client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": "你是一位专业的翻译。请严格按编号输出翻译。"},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
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()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
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
|
||||
translation: Optional[str] = None
|
||||
status: str = "pending" # pending, translated, ignored, failed
|
||||
error_msg: Optional[str] = None
|
||||
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": {},
|
||||
"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):
|
||||
"""初始化一个新的 Manifest。"""
|
||||
self.data = {
|
||||
"book_id": book_id,
|
||||
"metadata": metadata,
|
||||
"items": []
|
||||
}
|
||||
self._items_by_id = {}
|
||||
self.save()
|
||||
|
||||
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):
|
||||
"""更新翻译结果。"""
|
||||
if global_id in self._items_by_id:
|
||||
item = self._items_by_id[global_id]
|
||||
item.translation = translation
|
||||
item.status = status
|
||||
item.error_msg = error
|
||||
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,161 @@
|
||||
"""
|
||||
文本处理器模块 (Text Processor Module) - Manifest 驱动版
|
||||
|
||||
该模块专注于 HTML 文档的遍历和段落提取。
|
||||
它不再维护全局状态,而是将提取的内容注册到 ManifestManager 中。
|
||||
"""
|
||||
|
||||
import re
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Dict, Any
|
||||
from loguru import logger
|
||||
from .manifest_manager import ManifestManager
|
||||
|
||||
|
||||
class TextProcessor:
|
||||
"""
|
||||
负责从 HTML 中识别有效段落并进行清洗。
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
Args:
|
||||
config (Dict): 全局配置。
|
||||
"""
|
||||
self.config = config
|
||||
self.chunk_size = config['translation'].get('chunk_size', 5000)
|
||||
|
||||
def extract_to_manifest(self, html_content: str, source_file: str, manifest: ManifestManager):
|
||||
"""
|
||||
解析 HTML 内容,并将识别出的段落注册到 Manifest 中。
|
||||
|
||||
Args:
|
||||
html_content (str): HTML 源码。
|
||||
source_file (str): 来源文件名。
|
||||
manifest (ManifestManager): 清单管理器实例。
|
||||
"""
|
||||
try:
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
|
||||
# 1. 移除不需要的元素
|
||||
for element in soup(['script', 'style', 'meta', 'link']):
|
||||
element.decompose()
|
||||
|
||||
# 2. 获取有效的文本元素 (使用静态过滤逻辑)
|
||||
text_elements = self.get_valid_text_elements(soup)
|
||||
|
||||
# 3. 注册到 Manifest
|
||||
for element in text_elements:
|
||||
clean_text = self.clean_element_text(element)
|
||||
|
||||
# 过滤逻辑
|
||||
if not clean_text:
|
||||
continue
|
||||
|
||||
status = "pending"
|
||||
# 如果是导航元素,标记为 ignored
|
||||
if self.is_navigation_element(element):
|
||||
status = "ignored"
|
||||
|
||||
# 注册
|
||||
manifest.add_item(
|
||||
source_file=source_file,
|
||||
original_html=str(element),
|
||||
clean_text=clean_text,
|
||||
tag=element.name,
|
||||
metadata={"status": status} # 临时传递给 manifest
|
||||
)
|
||||
|
||||
# 同步更新 manifest 状态 (如果需要过滤)
|
||||
if status == "ignored":
|
||||
last_id = f"p_{len(manifest._items_by_id):05d}"
|
||||
manifest.update_item(last_id, translation=None, status="ignored")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从 {source_file} 提取段落失败: {e}")
|
||||
|
||||
@staticmethod
|
||||
def get_valid_text_elements(soup) -> List:
|
||||
"""获取不含嵌套子块的叶子级文本容器元素。"""
|
||||
tags = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'li', 'td']
|
||||
all_candidates = soup.find_all(tags)
|
||||
candidate_set = set(all_candidates)
|
||||
|
||||
final_elements = []
|
||||
for element in all_candidates:
|
||||
# 如果包含其他候选标签,说明是容器,跳过
|
||||
if any(d in candidate_set for d in element.find_all(tags)):
|
||||
continue
|
||||
final_elements.append(element)
|
||||
return final_elements
|
||||
|
||||
@staticmethod
|
||||
def clean_element_text(element) -> str:
|
||||
"""清理 HTML 元素,提取纯净的待翻译文本。"""
|
||||
element_copy = element.__copy__()
|
||||
|
||||
# 移除脚注引用等
|
||||
for tag in element_copy.find_all(['sup', 'sub']):
|
||||
tag.decompose()
|
||||
|
||||
footnote_patterns = re.compile(r'footnote|endnote|reference|note|super|sub', re.I)
|
||||
for tag in element_copy.find_all(['a', 'span', 'div'], class_=footnote_patterns):
|
||||
tag.decompose()
|
||||
|
||||
# 移除仅包含数字的 span
|
||||
for tag in element_copy.find_all('span'):
|
||||
if re.match(r'^(\[\d+\]|\(\d+\)|\d+)$', tag.get_text().strip()):
|
||||
tag.decompose()
|
||||
|
||||
text = element_copy.get_text().strip()
|
||||
# 正则清理残留引用标识 (如 sentence.2)
|
||||
text = re.sub(r'(\.|。|,|,)\s*(\[\d+\]|\d+)(?=\s|$)', r'\1', text)
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def is_navigation_element(element) -> bool:
|
||||
"""判断是否是无翻译价值的导航、页码元素。"""
|
||||
classes = element.get('class', [])
|
||||
nav_classes = ['nav', 'navigation', 'toc', 'menu', 'header', 'footer', 'page-number']
|
||||
class_str = ' '.join(classes).lower() if isinstance(classes, list) else str(classes).lower()
|
||||
|
||||
if any(nc in class_str for nc in nav_classes):
|
||||
return True
|
||||
|
||||
# 检查父级
|
||||
parent = element.parent
|
||||
if parent:
|
||||
p_classes = parent.get('class', [])
|
||||
p_class_str = ' '.join(p_classes).lower() if isinstance(p_classes, list) else str(p_classes).lower()
|
||||
if any(nc in p_class_str for nc in nav_classes):
|
||||
return True
|
||||
return False
|
||||
|
||||
def create_chunks_from_manifest(self, manifest: ManifestManager) -> 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.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,142 @@
|
||||
"""
|
||||
EPUB 翻译器核心模块 (EPUB Translator Core Module) - Manifest 驱动版
|
||||
|
||||
该模块协调整体流程:
|
||||
1. 使用 ManifestManager 管理状态。
|
||||
2. 调用 EPUBParser 提取。
|
||||
3. 调用 TextProcessor 清理。
|
||||
4. 调用 LLMClient 并发翻译并更新 Manifest。
|
||||
5. 调用 BilingualEPUBBuilder 构建。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
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 .llm_client import OpenRouterClient
|
||||
from .text_processor import TextProcessor
|
||||
from .bilingual_builder import BilingualEPUBBuilder
|
||||
from .manifest_manager import ManifestManager
|
||||
|
||||
|
||||
class EPUBTranslator:
|
||||
"""
|
||||
基于 Manifest 的翻译器。
|
||||
"""
|
||||
|
||||
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 = OpenRouterClient(config)
|
||||
self.text_processor = TextProcessor(config)
|
||||
|
||||
# Manifest 管理 (存放于 cache/manifests/ 目录下)
|
||||
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) -> str:
|
||||
"""主翻译流程。"""
|
||||
epub_path = Path(epub_path)
|
||||
# 1. 初始化解析器
|
||||
self.parser = EPUBParser(str(epub_path))
|
||||
|
||||
# 2. 准备 Manifest
|
||||
manifest_path = self.manifest_dir / f"{epub_path.stem}_manifest.json"
|
||||
manifest = ManifestManager(str(manifest_path))
|
||||
|
||||
# 检查是否能恢复
|
||||
if not manifest.load() or not self.use_cache:
|
||||
self.console.print("[yellow]初始化翻译清单...[/yellow]")
|
||||
manifest.init_manifest(book_id=epub_path.name, metadata=self.parser.get_book_info())
|
||||
|
||||
# 提取内容
|
||||
content_items = self.parser.extract_all_content_items()
|
||||
for item in content_items:
|
||||
self.text_processor.extract_to_manifest(item['content'], item['file_name'], manifest)
|
||||
manifest.save()
|
||||
|
||||
stats = manifest.stats
|
||||
self.console.print(f"[green]已加载清单: {stats['total']} 个段落, 已完成 {stats['progress_percent']}%[/green]")
|
||||
|
||||
if test_mode:
|
||||
# 简化逻辑:测试模式只翻译前几个 pending 项目
|
||||
pending = manifest.get_items(status="pending")[:5]
|
||||
if pending:
|
||||
results = await self.llm_client.translate_chunk(pending)
|
||||
for pid, trans in results.items():
|
||||
self.console.print(f"\n[cyan]{pid}[/cyan]: {trans}")
|
||||
return "test_mode_done"
|
||||
|
||||
# 3. 分块并并发翻译
|
||||
chunks = self.text_processor.create_chunks_from_manifest(manifest)
|
||||
if chunks:
|
||||
await self._translate_concurrently(chunks, manifest)
|
||||
|
||||
# 4. 构建双语 EPUB
|
||||
self.console.print("\n[yellow]正在构建双语 EPUB...[/yellow]")
|
||||
output_path = output_dir or self.config['output']['output_dir']
|
||||
builder = BilingualEPUBBuilder(self.parser.book, self.config)
|
||||
|
||||
# 注意:Builder 现在直接从 Manifest 中读取翻译映射
|
||||
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,
|
||||
"text": item.clean_text,
|
||||
"html_element": item.original_html
|
||||
} for item in manifest.get_items()}
|
||||
|
||||
result_file = builder.create_bilingual_epub_with_mapping(
|
||||
translation_map,
|
||||
paragraph_map,
|
||||
output_path
|
||||
)
|
||||
|
||||
self.console.print(f"[green]✅ 翻译完成!输出文件: {result_file}[/green]")
|
||||
return result_file
|
||||
|
||||
async def _translate_concurrently(self, chunks: List[List[Any]], manifest: ManifestManager):
|
||||
"""执行并发翻译任务。"""
|
||||
total_chunks = len(chunks)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
TimeElapsedColumn(),
|
||||
console=self.console
|
||||
) as progress:
|
||||
|
||||
task_id = progress.add_task(f"[cyan]并行翻译...", total=total_chunks)
|
||||
|
||||
# 使用可控并发
|
||||
semaphore = self.llm_client.rate_limiter.semaphore
|
||||
|
||||
async def worker(chunk, idx):
|
||||
async with semaphore:
|
||||
try:
|
||||
results = await self.llm_client.translate_chunk(chunk)
|
||||
# 更新 manifest
|
||||
for item in chunk:
|
||||
if item.global_id in results:
|
||||
manifest.update_item(item.global_id, results[item.global_id])
|
||||
else:
|
||||
manifest.update_item(item.global_id, None, status="failed", error="Missing in response")
|
||||
# 每翻译完一个 chunk 就保存一次,确保断点续传
|
||||
manifest.save()
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {idx} 翻译失败: {e}")
|
||||
finally:
|
||||
progress.update(task_id, advance=1)
|
||||
|
||||
tasks = [worker(chunk, i) for i, chunk in enumerate(chunks)]
|
||||
await asyncio.gather(*tasks)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
工具函数模块
|
||||
提供配置加载、日志设置等通用功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from loguru import logger
|
||||
import sys
|
||||
|
||||
|
||||
def load_config(config_path: str = "config/config.json") -> Dict[str, Any]:
|
||||
"""
|
||||
加载配置文件
|
||||
|
||||
Args:
|
||||
config_path: 配置文件路径
|
||||
|
||||
Returns:
|
||||
配置字典
|
||||
"""
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# 从环境变量获取 API Key
|
||||
if 'OPENROUTER_API_KEY' in os.environ:
|
||||
config['openrouter']['api_key'] = os.environ['OPENROUTER_API_KEY']
|
||||
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"配置文件未找到: {config_path}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"配置文件格式错误: {e}")
|
||||
|
||||
|
||||
def load_prompts(prompts_path: str = "config/prompts.json") -> Dict[str, str]:
|
||||
"""
|
||||
加载提示词模板
|
||||
|
||||
Args:
|
||||
prompts_path: 提示词文件路径
|
||||
|
||||
Returns:
|
||||
提示词字典
|
||||
"""
|
||||
try:
|
||||
with open(prompts_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"提示词文件未找到: {prompts_path}")
|
||||
|
||||
|
||||
def setup_logging(config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
设置日志配置
|
||||
|
||||
Args:
|
||||
config: 配置字典
|
||||
"""
|
||||
log_config = config.get('logging', {})
|
||||
|
||||
# 移除默认处理器
|
||||
logger.remove()
|
||||
|
||||
# 添加控制台输出
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level=log_config.get('level', 'INFO'),
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
|
||||
)
|
||||
|
||||
# 添加文件输出
|
||||
if 'file' in log_config:
|
||||
log_file = log_config['file']
|
||||
# 确保日志目录存在
|
||||
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.add(
|
||||
log_file,
|
||||
level=log_config.get('level', 'INFO'),
|
||||
rotation=log_config.get('rotation', '10 MB'),
|
||||
retention=log_config.get('retention', '7 days'),
|
||||
encoding='utf-8',
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}"
|
||||
)
|
||||
|
||||
|
||||
def ensure_output_dir(output_dir: str) -> Path:
|
||||
"""
|
||||
确保输出目录存在
|
||||
|
||||
Args:
|
||||
output_dir: 输出目录路径
|
||||
|
||||
Returns:
|
||||
输出目录的 Path 对象
|
||||
"""
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
return output_path
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
清理文件名,移除非法字符
|
||||
|
||||
Args:
|
||||
filename: 原始文件名
|
||||
|
||||
Returns:
|
||||
清理后的文件名
|
||||
"""
|
||||
import re
|
||||
# 移除或替换非法字符
|
||||
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
# 移除多余的空格和点
|
||||
filename = re.sub(r'\s+', ' ', filename).strip('. ')
|
||||
return filename
|
||||
|
||||
|
||||
def format_file_size(size_bytes: int) -> str:
|
||||
"""
|
||||
格式化文件大小显示
|
||||
|
||||
Args:
|
||||
size_bytes: 字节数
|
||||
|
||||
Returns:
|
||||
格式化的大小字符串
|
||||
"""
|
||||
if size_bytes == 0:
|
||||
return "0B"
|
||||
|
||||
size_names = ["B", "KB", "MB", "GB"]
|
||||
import math
|
||||
i = int(math.floor(math.log(size_bytes, 1024)))
|
||||
p = math.pow(1024, i)
|
||||
s = round(size_bytes / p, 2)
|
||||
return f"{s} {size_names[i]}"
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""
|
||||
估算文本的 token 数量
|
||||
|
||||
Args:
|
||||
text: 输入文本
|
||||
|
||||
Returns:
|
||||
估算的 token 数量
|
||||
"""
|
||||
# 简单估算:英文约 4 字符/token,中文约 1.5 字符/token
|
||||
import re
|
||||
|
||||
# 分离中英文
|
||||
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
|
||||
other_chars = len(text) - chinese_chars
|
||||
|
||||
# 估算 tokens
|
||||
estimated_tokens = chinese_chars / 1.5 + other_chars / 4
|
||||
return int(estimated_tokens)
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int = 100) -> str:
|
||||
"""
|
||||
截断文本用于显示
|
||||
|
||||
Args:
|
||||
text: 原始文本
|
||||
max_length: 最大长度
|
||||
|
||||
Returns:
|
||||
截断后的文本
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length-3] + "..."
|
||||
Reference in New Issue
Block a user