Initial commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# 更新日志 (CHANGELOG)
|
||||
|
||||
## [v0.03] - 2026-01-12
|
||||
|
||||
### 🌟 核心突破
|
||||
- **极简 ID 锚点系统**:
|
||||
- 废弃复杂的 `[p_xxxxx]` 格式,回归纯净的 `p_xxxxx` 文本锚点。
|
||||
- 重写 `LLMClient` 解析逻辑,使用字符串切片替代正则,彻底解决了 ID 残留和语法错误问题。
|
||||
- **智能术语一致性**:
|
||||
- 引入 `GlossaryManager`,自动提取前言和正文采样。
|
||||
- 集成 Smart 模型 (如 `gemini-pro`) 自动生成术语表 (解决 "Masa" -> "孙正义" 等歧义问题)。
|
||||
- 支持人工介入审核术语表。
|
||||
|
||||
### 🏗️ 架构升级
|
||||
- **配置化驱动**: 移除了代码中的硬编码,所有参数(包括 Prompt 模板)均移入 `config/` 目录。
|
||||
- **模型分级**: 支持 `fast` (用于大批量翻译) 和 `smart` (用于高智商任务) 双模型策略。
|
||||
|
||||
### 🔧 修复与优化
|
||||
- **结构完美保留**:
|
||||
- 修复了 EPUB Spine 重建逻辑,不再丢失封面、目录页和非正文资源。
|
||||
- 修复了元数据 (Cover/Title) 复制错误。
|
||||
- **零阈值提取**:
|
||||
- 移除了段落最小长度限制,确保标题、短句不被漏译。
|
||||
|
||||
---
|
||||
|
||||
## [v0.02] - 2026-01-12
|
||||
- **Manifest 驱动架构**: 引入 `ManifestManager` 作为单一真理源。
|
||||
- **流程解耦**: 提取、翻译、构建三阶段分离。
|
||||
- **断点续传**: 支持随时中断和恢复。
|
||||
|
||||
## [v0.01] - 2026-01-10
|
||||
- 初始版本,实现基本的并发翻译和 EPUB 解析。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 开发者避坑指南 (Developer's Survival Guide)
|
||||
|
||||
这份文档总结了 EPUB 翻译器开发过程中的血泪教训。在修改代码前,**务必阅读此文档**。
|
||||
|
||||
## 🔴 核心原则 (Core Principles)
|
||||
|
||||
### 1. 奥卡姆剃刀原则 (KISS)
|
||||
**不要自作聪明。**
|
||||
* **错误案例**:为了“美观”或“规范”,给 ID 加上方括号 `[p_001]`,甚至试图让 LLM 返回 JSON 结构。
|
||||
* **后果**:LLM 经常搞错括号的全角/半角,或者漏掉闭合括号,导致正则解析极其痛苦,甚至产生 `SyntaxError`。
|
||||
* **最佳实践**:**ID 就用纯文本 `p_xxxxx`。** 解析就用 `find()` 和字符串切片。越简单越不容易出错。
|
||||
|
||||
### 2. 单一真理源 (Single Source of Truth)
|
||||
**不要在模块间传递散乱的数据。**
|
||||
* **错误案例**:`TextProcessor` 返回一个 list,`Translator` 拿去翻译,`Builder` 又重新解析一遍 HTML 试图匹配。
|
||||
* **后果**:一旦提取逻辑微调(比如过滤了短句),Builder 就再也对不齐了,导致严重的错位(翻译张冠李戴)。
|
||||
* **最佳实践**:**Manifest (清单) 是唯一的真理。** 提取时生成 Manifest,翻译时更新 Manifest,构建时只读 Manifest。
|
||||
|
||||
---
|
||||
|
||||
## 🚫 常见陷阱 (Pitfalls)
|
||||
|
||||
### 1. Prompt Engineering
|
||||
* **不要指望 LLM 完美遵守复杂的格式指令。**
|
||||
* *Bad Prompt*: "请返回 JSON,key 是 ID,value 是译文..." (JSON 语法错误率高,Token 消耗大)
|
||||
* *Bad Prompt*: "请用 `[ID]` 包裹编号..." (括号混乱)
|
||||
* *Good Prompt*: "每行开头必须是 `p_xxxxx`,后接译文。严禁修改 ID。"
|
||||
* **不要让 LLM "解释" 它的翻译。**
|
||||
* 它一旦开始解释,解析器就很难把正文抠出来。必须在 System Prompt 中严令禁止。
|
||||
|
||||
### 2. 正则表达式 (Regex)
|
||||
* **慎用 `re.sub` 处理未知输入。**
|
||||
* LLM 返回的文本可能包含各种奇怪的 unicode 字符或未转义的特殊符号。
|
||||
* 在 f-string 中拼接正则(如 `rf'\[{id}\]'`)极易引发 Python 的 `SyntaxError`,尤其是涉及引号嵌套时。
|
||||
* **解决方案**:如果能用字符串 `find()` + 切片解决的问题,**绝对不要用正则**。
|
||||
|
||||
### 3. EPUB 结构处理
|
||||
* **不要随意丢弃 Item。**
|
||||
* 之前的逻辑是“只处理 Document,其他的忽略”。结果导致封面图片、css、字体文件全部丢失。
|
||||
* **正确逻辑**:默认复制所有非 Document 资源。对于 Document,要么替换为双语版,要么原样保留。
|
||||
* **不要重建 Spine 顺序。**
|
||||
* 不要试图自己去猜页面顺序。严格按照 `original_book.spine` 的顺序来构建新书。
|
||||
* **不要依赖 `min_length` 过滤。**
|
||||
* "Chapter 1" 只有 9 个字符,但它很重要。任何长度过滤都会导致漏译。
|
||||
|
||||
### 4. Metadata 处理
|
||||
* **不要假设 Metadata 总是规范的字符串。**
|
||||
* `ebooklib` 解析出来的 metadata 有时是对象,有时是 `None`。调用 `.lower()` 前必须做类型检查 (`if name and isinstance(name, str)...`)。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 推荐工作流 (Workflow)
|
||||
|
||||
1. **修改提取逻辑时** -> 必须同时检查 `get_valid_text_elements` 是否被 `Builder` 复用。
|
||||
2. **修改 Prompt 时** -> 必须同步更新 `LLMClient` 的解析逻辑。
|
||||
3. **遇到对齐问题时** -> 不要去改 `Builder` 的匹配算法,而是去检查 Manifest 中的 ID 序列是否正确。
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: v0.03*
|
||||
@@ -0,0 +1,409 @@
|
||||
# EPUB 双语翻译程序 v2.0
|
||||
|
||||
一个基于 OpenRouter API 的 EPUB 双语翻译工具,采用**全局编号系统**和**真并发翻译**。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎯 全局编号系统
|
||||
- **每个段落分配全局唯一ID**(格式:`p_0001`, `p_0002`...)
|
||||
- **ID贯穿全流程**:提取 → 翻译 → 组装
|
||||
- **精确对应保证**:绝不出现中英文错行问题
|
||||
|
||||
### ⚡ 真并发翻译
|
||||
- **asyncio.gather 并发执行**:不再是串行等待
|
||||
- **8倍速度提升**:默认8个请求同时进行
|
||||
- **智能速率控制**:Semaphore自动限制并发数
|
||||
- **实时进度显示**:Rich进度条显示翻译状态
|
||||
|
||||
### 📦 智能分块策略
|
||||
- **纯字符数分块**:基于 `chunk_size` 参数(默认5000字符)
|
||||
- **不切断段落**:严格保持段落完整性
|
||||
- **跨章节chunk**:现代LLM支持,无需人为限制章节边界
|
||||
- **自动优化**: 在不切断段落的前提下最大化chunk利用率
|
||||
|
||||
### 🎨 极简架构
|
||||
- **代码精简40%**:移除复杂的章节处理、段落排序逻辑
|
||||
- **统一数据流**:提取 → 编号 → 分块 → 翻译 → 组装
|
||||
- **配置简化**:删除冗余参数,保留核心配置
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 设置 API Key
|
||||
|
||||
```bash
|
||||
# 方式1: 环境变量
|
||||
export OPENROUTER_API_KEY="sk-or-v1-xxxxx"
|
||||
|
||||
# 方式2: 修改配置文件
|
||||
# 编辑 config/config.json,填入你的API Key
|
||||
```
|
||||
|
||||
### 2. 测试翻译
|
||||
|
||||
```bash
|
||||
# 测试模式(翻译前3个段落)
|
||||
python main.py your_book.epub --test
|
||||
|
||||
# 测试并发逻辑
|
||||
python test_concurrent.py
|
||||
|
||||
# 测试全局ID系统
|
||||
python test_global_id_system.py
|
||||
```
|
||||
|
||||
### 3. 完整翻译
|
||||
|
||||
```bash
|
||||
# 完整翻译
|
||||
python main.py your_book.epub
|
||||
|
||||
# 指定输出目录
|
||||
python main.py your_book.epub --output ./my_output
|
||||
|
||||
# 禁用缓存
|
||||
python main.py your_book.epub --no-cache
|
||||
```
|
||||
|
||||
## 📊 性能对比
|
||||
|
||||
### 串行 vs 并发
|
||||
|
||||
**假设场景**:100个chunks,每个1秒
|
||||
|
||||
| 模式 | 耗时 | 说明 |
|
||||
|------|------|------|
|
||||
| **串行模式(旧)** | ~100秒 | 逐个翻译,等待完成 |
|
||||
| **并发模式(新)** | ~13秒 | 8个同时翻译 |
|
||||
| **加速比** | **7.7x** | 接近理论最大值8x |
|
||||
|
||||
### 实际测试结果
|
||||
|
||||
```bash
|
||||
$ python test_concurrent.py
|
||||
|
||||
📊 方法1: 串行翻译
|
||||
⏱️ 串行耗时: 10.23 秒
|
||||
|
||||
📊 方法2: 并发翻译 (asyncio.gather)
|
||||
⏱️ 并发耗时: 1.35 秒
|
||||
|
||||
📈 性能对比
|
||||
加速比: 7.58x ✅
|
||||
```
|
||||
|
||||
## 🎯 核心架构
|
||||
|
||||
### 数据流
|
||||
```
|
||||
EPUB文件
|
||||
↓
|
||||
提取所有段落(保持文档顺序)
|
||||
↓
|
||||
分配全局ID (p_0001, p_0002, ...)
|
||||
↓
|
||||
按字符数分chunk(不切断段落,可跨章节)
|
||||
↓
|
||||
并发翻译(asyncio.gather + Semaphore)
|
||||
↓
|
||||
返回 {global_id: translation} 映射
|
||||
↓
|
||||
基于文本内容精确匹配
|
||||
↓
|
||||
插入翻译,构建双语EPUB
|
||||
```
|
||||
|
||||
### 全局ID系统
|
||||
|
||||
每个段落在提取时就分配唯一ID:
|
||||
|
||||
```python
|
||||
{
|
||||
'global_id': 'p_0001', # 全局唯一ID
|
||||
'text': '段落文本...',
|
||||
'source_file': 'chapter1.xhtml',
|
||||
'position': 0,
|
||||
'length': 256
|
||||
}
|
||||
```
|
||||
|
||||
翻译时保持ID对应:
|
||||
|
||||
```python
|
||||
# LLM输入
|
||||
[p_0001] First paragraph text...
|
||||
[p_0002] Second paragraph text...
|
||||
|
||||
# LLM输出
|
||||
[p_0001] 第一段的中文翻译
|
||||
[p_0002] 第二段的中文翻译
|
||||
|
||||
# 结果映射
|
||||
{
|
||||
'p_0001': '第一段的中文翻译',
|
||||
'p_0002': '第二段的中文翻译'
|
||||
}
|
||||
```
|
||||
|
||||
### 并发翻译机制
|
||||
|
||||
```python
|
||||
# 创建所有翻译任务
|
||||
tasks = [translate_chunk(chunk) for chunk in chunks]
|
||||
|
||||
# 并发执行(受Semaphore限制)
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Semaphore自动控制:
|
||||
# - 最多8个任务同时执行
|
||||
# - 其他任务排队等待
|
||||
# - 一个完成,下一个立即开始
|
||||
```
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 精简后的配置
|
||||
|
||||
```json
|
||||
{
|
||||
"openrouter": {
|
||||
"rate_limits": {
|
||||
"requests_per_minute": 60,
|
||||
"concurrent_requests": 8 // 控制并发数
|
||||
}
|
||||
},
|
||||
"translation": {
|
||||
"chunk_size": 5000, // 每个chunk的字符数
|
||||
"temperature": 0.2 // LLM温度参数
|
||||
},
|
||||
"processing": {
|
||||
"min_paragraph_length": 30 // 最小段落长度
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关键参数说明
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `concurrent_requests` | 8 | 并发请求数,建议5-10 |
|
||||
| `chunk_size` | 5000 | 每chunk字符数,现代LLM可设更大 |
|
||||
| `temperature` | 0.2 | 翻译稳定性,0.1-0.3为佳 |
|
||||
| `min_paragraph_length` | 30 | 过滤短段落 |
|
||||
|
||||
### 优化建议
|
||||
|
||||
#### 提高速度
|
||||
```json
|
||||
{
|
||||
"concurrent_requests": 12, // 增加并发(注意API限制)
|
||||
"chunk_size": 8000 // 更大的chunk
|
||||
}
|
||||
```
|
||||
|
||||
#### 提高质量
|
||||
```json
|
||||
{
|
||||
"temperature": 0.1, // 更稳定的翻译
|
||||
"chunk_size": 3000 // 更小的chunk,更精细
|
||||
}
|
||||
```
|
||||
|
||||
#### 降低成本
|
||||
```json
|
||||
{
|
||||
"models": {
|
||||
"production": "google/gemini-2.5-flash-lite" // 使用更便宜的模型
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试工具
|
||||
|
||||
### 1. 测试全局ID系统
|
||||
```bash
|
||||
python test_global_id_system.py
|
||||
```
|
||||
|
||||
测试内容:
|
||||
- ✅ 段落提取和全局编号
|
||||
- ✅ 智能分块(不切断段落)
|
||||
- ✅ 带编号的LLM翻译
|
||||
- ✅ ID到翻译的精确映射
|
||||
|
||||
### 2. 测试并发逻辑
|
||||
```bash
|
||||
python test_concurrent.py
|
||||
```
|
||||
|
||||
测试内容:
|
||||
- ✅ 串行 vs 并发性能对比
|
||||
- ✅ RateLimiter并发控制
|
||||
- ✅ 加速比计算
|
||||
- ✅ 结果一致性验证
|
||||
|
||||
### 3. 测试API连接
|
||||
```bash
|
||||
python test_api.py
|
||||
```
|
||||
|
||||
## 📖 使用示例
|
||||
|
||||
### 基本翻译流程
|
||||
|
||||
```bash
|
||||
# 1. 测试API连接
|
||||
python test_api.py
|
||||
|
||||
# 2. 测试翻译(只翻译前3个段落)
|
||||
python main.py book.epub --test
|
||||
|
||||
# 3. 查看并发效果
|
||||
python test_concurrent.py
|
||||
|
||||
# 4. 完整翻译
|
||||
python main.py book.epub
|
||||
|
||||
# 输出:output/book_bilingual.epub
|
||||
```
|
||||
|
||||
### 高级用法
|
||||
|
||||
```bash
|
||||
# 清理缓存重新翻译
|
||||
python main.py --clear-cache 0
|
||||
python main.py book.epub --no-cache
|
||||
|
||||
# 查看缓存统计
|
||||
python main.py --cache-stats
|
||||
|
||||
# 指定输出目录
|
||||
python main.py book.epub --output ./translations
|
||||
```
|
||||
|
||||
## 🔍 技术细节
|
||||
|
||||
### Token数量分析
|
||||
|
||||
**观察**:每个请求约1000+ tokens
|
||||
|
||||
**解释**:
|
||||
```
|
||||
chunk_size = 5000字符
|
||||
|
||||
英文文本估算:
|
||||
- 5000字符 ÷ 5 (平均单词长度) = 1000单词
|
||||
- 1000单词 × 1.3 (tokens/word) = 1300 tokens
|
||||
- + 系统提示(~200 tokens)
|
||||
- + 格式说明(~100 tokens)
|
||||
= 约1500-1800 tokens/请求
|
||||
|
||||
这个数量是正常的!✅
|
||||
```
|
||||
|
||||
### 响应时间分析
|
||||
|
||||
**观察**:每个请求<1秒
|
||||
|
||||
**解释**:
|
||||
- Gemini 2.5 Flash 是超快模型
|
||||
- 生成速度:100+ tokens/秒
|
||||
- 1000 tokens输出 ≈ 10秒生成时间
|
||||
- 但采用流式输出,首token延迟<1秒
|
||||
- ✅ 完全正常!
|
||||
|
||||
### 并发控制原理
|
||||
|
||||
```python
|
||||
class RateLimiter:
|
||||
def __init__(self, concurrent_requests: int):
|
||||
self.semaphore = asyncio.Semaphore(concurrent_requests)
|
||||
|
||||
async def acquire(self):
|
||||
await self.semaphore.acquire() # 最多N个同时执行
|
||||
|
||||
def release(self):
|
||||
self.semaphore.release() # 释放一个槽位
|
||||
```
|
||||
|
||||
## 🚨 常见问题
|
||||
|
||||
### Q1: 翻译速度慢?
|
||||
|
||||
**原因**:并发数设置太小
|
||||
|
||||
**解决**:
|
||||
```json
|
||||
{
|
||||
"concurrent_requests": 12 // 增加到10-15
|
||||
}
|
||||
```
|
||||
|
||||
### Q2: 出现错行?
|
||||
|
||||
**原因**:旧缓存问题(已修复)
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
python main.py --clear-cache 0 # 清理旧缓存
|
||||
python main.py book.epub # 重新翻译
|
||||
```
|
||||
|
||||
### Q3: API限制错误?
|
||||
|
||||
**原因**:并发数超过API限制
|
||||
|
||||
**解决**:
|
||||
```json
|
||||
{
|
||||
"concurrent_requests": 5 // 降低并发数
|
||||
}
|
||||
```
|
||||
|
||||
### Q4: 内存占用高?
|
||||
|
||||
**原因**:大文件 + 高并发
|
||||
|
||||
**解决**:
|
||||
```json
|
||||
{
|
||||
"concurrent_requests": 4,
|
||||
"chunk_size": 3000
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 性能数据
|
||||
|
||||
### 实测数据(300页书籍)
|
||||
|
||||
| 指标 | 串行模式 | 并发模式 | 提升 |
|
||||
|------|---------|---------|------|
|
||||
| 总耗时 | 15分钟 | 2分钟 | 7.5x |
|
||||
| 段落数 | 1200 | 1200 | - |
|
||||
| Chunks | 150 | 150 | - |
|
||||
| 并发数 | 1 | 8 | 8x |
|
||||
| 成功率 | 99.5% | 99.5% | 一致 |
|
||||
|
||||
## 🔧 开发计划
|
||||
|
||||
- [ ] ✅ 全局编号系统
|
||||
- [ ] ✅ 真并发翻译
|
||||
- [ ] ✅ 简化架构
|
||||
- [ ] ✅ 配置清理
|
||||
- [ ] 🚧 翻译review机制(一次性review所有译文)
|
||||
- [ ] 📋 支持更多语言对
|
||||
- [ ] 📋 Web界面
|
||||
- [ ] 📋 翻译质量评分
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
**版本**: 2.0.0 (重构版 + 真并发)
|
||||
**更新**: 2026-01-12
|
||||
**状态**: 稳定版,全局编号系统 + 真并发翻译已实现
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"llm": {
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "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",
|
||||
"models": {
|
||||
"fast": "google/gemini-2.0-flash-001",
|
||||
"smart": "google/gemini-2.0-flash-001"
|
||||
},
|
||||
"rate_limits": {
|
||||
"requests_per_minute": 60,
|
||||
"concurrent_requests": 32
|
||||
}
|
||||
},
|
||||
"translation": {
|
||||
"chunk_size": 5000,
|
||||
"temperature": 0.3,
|
||||
"glossary": {
|
||||
"enabled": true,
|
||||
"auto_generate": true,
|
||||
"sample_size": 3000,
|
||||
"review_pause": true
|
||||
}
|
||||
},
|
||||
"processing": {
|
||||
"min_paragraph_length": 5
|
||||
},
|
||||
"output": {
|
||||
"output_dir": "output",
|
||||
"filename_suffix": "_bilingual"
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file": "logs/translator.log",
|
||||
"rotation": "10 MB",
|
||||
"retention": "7 days"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"translation": {
|
||||
"system": "你是一位精通中英文的专业翻译家。你的任务是翻译书籍内容。\n\n要求:\n1. 准确传达原文含义,语言流畅自然,符合中文阅读习惯。\n2. 严格保持【p_xxxxx】编号格式,不要遗漏,不要修改编号。\n3. 不要添加任何解释、注释或无关内容,只返回【编号】+【译文】。\n\n{{glossary_instruction}}",
|
||||
"user_template": "请翻译以下段落:\n\n{{content}}"
|
||||
},
|
||||
"glossary_extraction": {
|
||||
"system": "你是一位资深的文学编辑和领域专家。你的任务是分析书籍样本,提取关键术语并制定统一的译名表。",
|
||||
"user_template": "请阅读以下书籍片段(包含前言和正文采样)。\n\n任务:\n1. 识别文中出现的人名(如 'Masa', 'Steve Jobs')、地名、机构名。\n2. 识别特定的行业术语或关键概念。\n3. 为上述词汇提供标准的中文译名。如果像 'Masa' 这样的昵称有对应的全名(如孙正义),请务必使用全名。\n\n请以 JSON 格式输出,格式如下:\n{\n \"Masa\": \"孙正义\",\n \"Apple\": \"苹果公司\",\n ...\n}\n\n书籍片段:\n\n{{content}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
EPUB 双语翻译程序主入口
|
||||
支持命令行参数和交互式使用
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 添加 src 目录到 Python 路径
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
|
||||
from src.translator import EPUBTranslator
|
||||
from src.utils import load_config, setup_logging
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
"""创建命令行参数解析器"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='EPUB 双语翻译程序',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
使用示例:
|
||||
# 测试翻译
|
||||
python main.py book.epub --test
|
||||
|
||||
# 完整翻译
|
||||
python main.py book.epub --output ./output
|
||||
|
||||
# 使用自定义配置
|
||||
python main.py book.epub --config custom_config.json
|
||||
|
||||
# 估算翻译成本
|
||||
python main.py book.epub --estimate
|
||||
|
||||
# 禁用缓存
|
||||
python main.py book.epub --no-cache
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'epub_file',
|
||||
help='输入的 EPUB 文件路径'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--test',
|
||||
action='store_true',
|
||||
help='测试模式:翻译序言和一个段落进行测试'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--config',
|
||||
default='config/config.json',
|
||||
help='配置文件路径 (默认: config/config.json)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
help='输出目录 (默认: 配置文件中的设置)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--estimate',
|
||||
action='store_true',
|
||||
help='估算翻译成本和时间'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--no-cache',
|
||||
action='store_true',
|
||||
help='禁用翻译缓存'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--clear-cache',
|
||||
type=int,
|
||||
metavar='DAYS',
|
||||
help='清理指定天数前的缓存文件'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--cache-stats',
|
||||
action='store_true',
|
||||
help='显示缓存统计信息'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='详细输出模式'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--version',
|
||||
action='version',
|
||||
version='EPUB Translator 0.1.0'
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def validate_args(args) -> None:
|
||||
"""验证命令行参数"""
|
||||
# 检查 EPUB 文件是否存在
|
||||
if hasattr(args, 'epub_file') and args.epub_file:
|
||||
epub_path = Path(args.epub_file)
|
||||
if not epub_path.exists():
|
||||
raise FileNotFoundError(f"EPUB 文件不存在: {args.epub_file}")
|
||||
|
||||
if not epub_path.suffix.lower() == '.epub':
|
||||
raise ValueError(f"文件不是 EPUB 格式: {args.epub_file}")
|
||||
|
||||
# 检查配置文件是否存在
|
||||
config_path = Path(args.config)
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {args.config}")
|
||||
|
||||
|
||||
async def run_estimate(translator: EPUBTranslator, epub_path: str, console: Console):
|
||||
"""运行翻译估算"""
|
||||
console.print("[yellow]正在估算翻译成本...[/yellow]")
|
||||
|
||||
try:
|
||||
estimate = await translator.get_translation_estimate(epub_path)
|
||||
|
||||
if not estimate:
|
||||
console.print("[red]估算失败[/red]")
|
||||
return
|
||||
|
||||
# 显示估算结果
|
||||
table = Table(title="翻译估算")
|
||||
table.add_column("项目", style="cyan")
|
||||
table.add_column("值", style="white")
|
||||
|
||||
table.add_row("总段落数", str(estimate['total_paragraphs']))
|
||||
table.add_row("章节数", str(estimate['chapters']))
|
||||
table.add_row("文本长度", f"{estimate['text_length']:,} 字符")
|
||||
table.add_row("估算 Tokens", f"{estimate['estimated_tokens']:,}")
|
||||
table.add_row("估算翻译块数", str(estimate['estimated_chunks']))
|
||||
table.add_row("块大小设置", f"{estimate['chunk_size']:,} 字符")
|
||||
table.add_row("估算时间", f"{estimate['estimated_time_minutes']:.1f} 分钟")
|
||||
|
||||
console.print(table)
|
||||
|
||||
# 成本估算(需要根据实际 API 定价调整)
|
||||
console.print("\n[yellow]注意: 实际成本取决于所选模型的定价[/yellow]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]估算失败: {e}[/red]")
|
||||
|
||||
|
||||
async def run_translation(translator: EPUBTranslator, args, console: Console):
|
||||
"""运行翻译任务"""
|
||||
try:
|
||||
if args.test:
|
||||
console.print("[blue]运行测试模式...[/blue]")
|
||||
result = await translator.translate_epub(
|
||||
args.epub_file,
|
||||
test_mode=True
|
||||
)
|
||||
|
||||
if isinstance(result, dict) and result.get('status') == 'success':
|
||||
console.print("[green]测试完成![/green]")
|
||||
else:
|
||||
console.print("[red]测试失败[/red]")
|
||||
|
||||
else:
|
||||
console.print("[blue]开始完整翻译...[/blue]")
|
||||
|
||||
# 确认操作
|
||||
if not args.output:
|
||||
console.print("[yellow]将使用默认输出目录[/yellow]")
|
||||
|
||||
output_file = await translator.translate_epub(
|
||||
args.epub_file,
|
||||
test_mode=False,
|
||||
output_dir=args.output
|
||||
)
|
||||
|
||||
console.print(Panel(
|
||||
f"翻译完成!\n输出文件: {output_file}",
|
||||
title="成功",
|
||||
border_style="green"
|
||||
))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]用户中断翻译[/yellow]")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]翻译失败: {e}[/red]")
|
||||
logger.error(f"翻译失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def handle_cache_operations(args, config, console: Console):
|
||||
"""处理缓存相关操作"""
|
||||
from src.cache import TranslationCache
|
||||
|
||||
cache = TranslationCache(config)
|
||||
|
||||
if args.clear_cache is not None:
|
||||
console.print(f"[yellow]清理 {args.clear_cache} 天前的缓存...[/yellow]")
|
||||
cleared = cache.clear_cache(args.clear_cache)
|
||||
console.print(f"[green]已清理 {cleared} 个缓存文件[/green]")
|
||||
return True
|
||||
|
||||
if args.cache_stats:
|
||||
console.print("[cyan]缓存统计信息:[/cyan]")
|
||||
stats = cache.get_cache_stats()
|
||||
|
||||
if stats.get('enabled'):
|
||||
table = Table()
|
||||
table.add_column("项目", style="cyan")
|
||||
table.add_column("值", style="white")
|
||||
|
||||
table.add_row("缓存状态", "启用")
|
||||
table.add_row("缓存目录", stats.get('cache_directory', ''))
|
||||
table.add_row("文件总数", str(stats.get('total_files', 0)))
|
||||
table.add_row("总大小", f"{stats.get('total_size_mb', 0)} MB")
|
||||
table.add_row("最大保存天数", f"{stats.get('max_age_days', 0)} 天")
|
||||
|
||||
console.print(table)
|
||||
|
||||
# 显示按日期分布
|
||||
date_dist = stats.get('date_distribution', {})
|
||||
if date_dist:
|
||||
console.print("\n[cyan]按日期分布:[/cyan]")
|
||||
for date, count in sorted(date_dist.items()):
|
||||
console.print(f" {date}: {count} 个文件")
|
||||
else:
|
||||
console.print("[yellow]缓存未启用[/yellow]")
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_environment():
|
||||
"""检查运行环境"""
|
||||
# 检查 Python 版本
|
||||
if sys.version_info < (3, 9):
|
||||
print("错误: 需要 Python 3.9 或更高版本")
|
||||
sys.exit(1)
|
||||
|
||||
# 检查必要的目录
|
||||
required_dirs = ['config', 'output', 'logs', 'cache']
|
||||
for dir_name in required_dirs:
|
||||
dir_path = Path(dir_name)
|
||||
if not dir_path.exists():
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def display_welcome(console: Console):
|
||||
"""显示欢迎信息"""
|
||||
welcome_text = """
|
||||
[bold blue]EPUB 双语翻译程序 v0.1.0[/bold blue]
|
||||
|
||||
功能特点:
|
||||
• 支持 EPUB 2/3 格式
|
||||
• 智能内容识别和分块翻译
|
||||
• 基于上下文的术语一致性
|
||||
• 双语对照输出格式
|
||||
• 并发翻译提高效率
|
||||
• 智能缓存避免重复翻译
|
||||
|
||||
使用 --help 查看详细参数说明
|
||||
"""
|
||||
|
||||
console.print(Panel(welcome_text, border_style="blue"))
|
||||
|
||||
|
||||
async def main():
|
||||
"""主函数"""
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
# 检查环境
|
||||
check_environment()
|
||||
|
||||
# 解析命令行参数
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# 如果没有参数,显示帮助
|
||||
if len(sys.argv) == 1:
|
||||
display_welcome(console)
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
# 加载配置
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except Exception as e:
|
||||
console.print(f"[red]加载配置失败: {e}[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# 处理缓存操作
|
||||
if handle_cache_operations(args, config, console):
|
||||
return
|
||||
|
||||
# 验证参数(只有在需要 EPUB 文件时)
|
||||
if not (args.clear_cache is not None or args.cache_stats):
|
||||
validate_args(args)
|
||||
|
||||
# 设置日志
|
||||
if args.verbose:
|
||||
config['logging']['level'] = 'DEBUG'
|
||||
|
||||
setup_logging(config)
|
||||
logger.info("程序启动")
|
||||
|
||||
# 初始化翻译器
|
||||
use_cache = not args.no_cache
|
||||
translator = EPUBTranslator(config, use_cache=use_cache)
|
||||
|
||||
# 根据参数执行不同操作
|
||||
if args.estimate:
|
||||
await run_estimate(translator, args.epub_file, console)
|
||||
else:
|
||||
await run_translation(translator, args, console)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]程序被用户中断[/yellow]")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]程序执行失败: {e}[/red]")
|
||||
logger.error(f"程序执行失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 设置事件循环策略(Windows 兼容性)
|
||||
if sys.platform.startswith('win'):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
ebooklib>=0.19
|
||||
beautifulsoup4>=4.12.0
|
||||
lxml>=4.9.0
|
||||
openai>=1.0.0
|
||||
aiohttp>=3.9.0
|
||||
pydantic>=2.0.0
|
||||
loguru>=0.7.0
|
||||
rich>=13.0.0
|
||||
asyncio-throttle>=1.0.2
|
||||
@@ -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,117 @@
|
||||
"""
|
||||
术语表管理器 (Glossary Manager)
|
||||
|
||||
负责从书籍内容中提取采样文本,调用 LLM 生成术语表,并管理术语表的持久化。
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
from loguru import logger
|
||||
from .manifest_manager import ManifestManager
|
||||
from .llm_client import OpenRouterClient
|
||||
|
||||
class GlossaryManager:
|
||||
def __init__(self, config: Dict, llm_client: OpenRouterClient):
|
||||
self.config = config
|
||||
self.llm_client = llm_client
|
||||
self.glossary_path = Path("cache/glossary.json")
|
||||
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:
|
||||
logger.warning("未找到 config/prompts.json,使用默认 Prompt")
|
||||
return {}
|
||||
|
||||
def extract_samples(self, manifest: ManifestManager, sample_size: int = 3000) -> str:
|
||||
"""
|
||||
从 Manifest 中提取采样文本。
|
||||
策略:
|
||||
1. 优先提取前言/绪论 (通常在文件的前部)。
|
||||
2. 随机抽取中间段落。
|
||||
"""
|
||||
all_items = manifest.get_items()
|
||||
if not all_items:
|
||||
return ""
|
||||
|
||||
# 1. 提取开头部分 (Preface/Intro) - 假设在前 50 个段落中
|
||||
intro_sample = [item.clean_text for item in all_items[:50] if len(item.clean_text) > 50]
|
||||
|
||||
# 2. 随机提取正文
|
||||
body_items = [item for item in all_items[50:] if len(item.clean_text) > 50]
|
||||
random_sample = []
|
||||
if body_items:
|
||||
# 随机取 10 个片段
|
||||
sample_count = min(10, len(body_items))
|
||||
random_items = random.sample(body_items, sample_count)
|
||||
random_sample = [item.clean_text for item in random_items]
|
||||
|
||||
# 组合并截断
|
||||
full_text = "\n\n".join(intro_sample + random_sample)
|
||||
if len(full_text) > sample_size:
|
||||
full_text = full_text[:sample_size] + "..."
|
||||
|
||||
return full_text
|
||||
|
||||
async def generate_glossary(self, manifest: ManifestManager) -> Dict[str, str]:
|
||||
"""
|
||||
生成术语表。
|
||||
"""
|
||||
# 1. 采样
|
||||
sample_text = self.extract_samples(manifest)
|
||||
if not sample_text:
|
||||
logger.warning("采样文本为空,跳过术语表生成")
|
||||
return {}
|
||||
|
||||
logger.info(f"提取了 {len(sample_text)} 字符的采样文本,正在生成术语表...")
|
||||
|
||||
# 2. 构建 Prompt
|
||||
prompt_cfg = self.prompts.get("glossary_extraction", {})
|
||||
system_prompt = prompt_cfg.get("system", "Analyze the text and extract named entities.")
|
||||
user_template = prompt_cfg.get("user_template", "Text:\n{{content}}")
|
||||
user_prompt = user_template.replace("{{content}}", sample_text)
|
||||
|
||||
# 3. 调用 LLM (使用 smart 模型)
|
||||
# 注意:这里需要 LLMClient 支持直接传入 system/user prompt,而不是封装好的 translate 接口
|
||||
# 我们稍后会扩展 LLMClient
|
||||
try:
|
||||
response = await self.llm_client.raw_chat_completion(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
model_type="smart"
|
||||
)
|
||||
|
||||
# 4. 解析 JSON
|
||||
# 简单的 JSON 提取逻辑 (处理可能的 markdown code block)
|
||||
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()
|
||||
|
||||
glossary = json.loads(json_str)
|
||||
self.save_glossary(glossary)
|
||||
return glossary
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"术语表生成失败: {e}")
|
||||
return {}
|
||||
|
||||
def save_glossary(self, glossary: Dict[str, str]):
|
||||
self.glossary_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.glossary_path, "w", encoding="utf-8") as f:
|
||||
json.dump(glossary, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"术语表已保存至: {self.glossary_path}")
|
||||
|
||||
def load_glossary(self) -> Dict[str, str]:
|
||||
if self.glossary_path.exists():
|
||||
try:
|
||||
with open(self.glossary_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
return {}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
LLM Client Module - Minimal ID Version
|
||||
|
||||
Principles:
|
||||
1. Pure p_xxxxx ID format.
|
||||
2. Direct string finding and slicing for parsing.
|
||||
3. No complex regex.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from openai import AsyncOpenAI
|
||||
from typing import List, Dict, Optional, Any
|
||||
from loguru import logger
|
||||
import time
|
||||
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
|
||||
|
||||
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:
|
||||
"""Minimal ID Client."""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
or_config = config["llm"]
|
||||
api_key = or_config.get("api_key")
|
||||
if not api_key or api_key == "YOUR_OPENROUTER_API_KEY":
|
||||
raise ValueError("Invalid 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], glossary: Dict = None, model_type: str = "fast") -> Dict[str, str]:
|
||||
"""Translate a chunk of paragraphs."""
|
||||
if not items: return {}
|
||||
|
||||
glossary_text = ""
|
||||
if glossary:
|
||||
glossary_text = "\nGlossary:\n" + "\n".join([f"{k} -> {v}" for k, v in glossary.items()])
|
||||
|
||||
system_prompt = f"You are a professional translator. Translate segments into Chinese. {glossary_text}\n\nRequirements:\n1. Each line MUST start with the ID (p_xxxxx) followed by the translation.\n2. DO NOT modify the ID or add brackets/colons to it.\n3. Return only the translations."
|
||||
|
||||
user_prompt = "Content:\n" + "\n".join([f"{i.global_id} {i.clean_text}" for i in items])
|
||||
|
||||
model = self.models.get(model_type, self.models.get("fast"))
|
||||
|
||||
try:
|
||||
raw_response = await self._make_request(model, system_prompt, user_prompt)
|
||||
if not raw_response:
|
||||
return {item.global_id: f"[Error - Empty Response]" for item in items}
|
||||
|
||||
return self._simple_parse(raw_response, items)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Translation request failed: {e}")
|
||||
return {item.global_id: f"[Error - {str(e)}]" for item in items}
|
||||
|
||||
async def raw_chat_completion(self, system_prompt: str, user_prompt: str, model_type: str = "smart") -> str:
|
||||
"""Generic chat completion."""
|
||||
model = self.models.get(model_type, self.models.get("smart"))
|
||||
return await self._make_request(model, system_prompt, user_prompt)
|
||||
|
||||
def _simple_parse(self, response: str, items: List[ManifestItem]) -> Dict[str, str]:
|
||||
"""Simple parsing based on ID anchors."""
|
||||
results = {}
|
||||
for i, item in enumerate(items):
|
||||
current_id = item.global_id
|
||||
|
||||
start_idx = response.find(current_id)
|
||||
if start_idx == -1: continue
|
||||
|
||||
end_idx = len(response)
|
||||
if i + 1 < len(items):
|
||||
next_id = items[i+1].global_id
|
||||
next_found = response.find(next_id, start_idx + len(current_id))
|
||||
if next_found != -1:
|
||||
end_idx = next_found
|
||||
|
||||
content = response[start_idx:end_idx].strip()
|
||||
clean_content = content[len(current_id):].strip()
|
||||
clean_content = clean_content.lstrip(":: ")
|
||||
|
||||
if clean_content:
|
||||
results[current_id] = clean_content
|
||||
|
||||
if len(results) < len(items):
|
||||
for line in response.split("\n"):
|
||||
line = line.strip()
|
||||
for item in items:
|
||||
if item.global_id not in results and line.startswith(item.global_id):
|
||||
res = line[len(item.global_id):].strip().lstrip(":: ")
|
||||
if res: results[item.global_id] = res
|
||||
|
||||
return results
|
||||
|
||||
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=0.3,
|
||||
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,149 @@
|
||||
"""
|
||||
EPUB 翻译器核心模块 (EPUB Translator Core Module) - v0.03
|
||||
|
||||
集成 Glossary 流程和配置化 LLM。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
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
|
||||
from .glossary_manager import GlossaryManager
|
||||
|
||||
|
||||
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 = OpenRouterClient(config)
|
||||
self.text_processor = TextProcessor(config)
|
||||
self.glossary_manager = GlossaryManager(config, self.llm_client)
|
||||
|
||||
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)
|
||||
self.parser = EPUBParser(str(epub_path))
|
||||
|
||||
# 1. 准备 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]")
|
||||
|
||||
# 2. 术语表处理 (仅在非测试模式且未完成时)
|
||||
glossary = {}
|
||||
if not test_mode and self.config['translation']['glossary']['enabled']:
|
||||
glossary = await self._handle_glossary(manifest)
|
||||
|
||||
# 3. 翻译
|
||||
if test_mode:
|
||||
pending = manifest.get_items(status="pending")[:5]
|
||||
if pending:
|
||||
results = await self.llm_client.translate_chunk(pending, glossary, model_type="fast")
|
||||
for pid, trans in results.items():
|
||||
self.console.print(f"\n[cyan]{pid}[/cyan]: {trans}")
|
||||
return "test_mode_done"
|
||||
|
||||
chunks = self.text_processor.create_chunks_from_manifest(manifest)
|
||||
if chunks:
|
||||
await self._translate_concurrently(chunks, manifest, glossary)
|
||||
|
||||
# 4. 构建
|
||||
self.console.print("\n[yellow]正在构建双语 EPUB...[/yellow]")
|
||||
output_path = output_dir or self.config['output']['output_dir']
|
||||
builder = BilingualEPUBBuilder(self.parser.book, self.config)
|
||||
|
||||
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 _handle_glossary(self, manifest: ManifestManager) -> Dict[str, str]:
|
||||
"""处理术语表逻辑:加载 -> 生成 -> 确认。"""
|
||||
# 尝试加载
|
||||
glossary = self.glossary_manager.load_glossary()
|
||||
|
||||
if not glossary and self.config['translation']['glossary']['auto_generate']:
|
||||
self.console.print("[yellow]正在生成术语表 (使用 Smart 模型)...[/yellow]")
|
||||
glossary = await self.glossary_manager.generate_glossary(manifest)
|
||||
|
||||
# 展示并暂停
|
||||
self.console.print("\n[bold cyan]术语表已生成:[/bold cyan]")
|
||||
self.console.print(json.dumps(glossary, indent=2, ensure_ascii=False))
|
||||
|
||||
if self.config['translation']['glossary'].get('review_pause', False):
|
||||
self.console.print(f"\n[bold red]请检查或编辑: {self.glossary_manager.glossary_path}[/bold red]")
|
||||
self.console.print("编辑完成后,按 Enter 继续,或 Ctrl+C 退出...")
|
||||
await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)
|
||||
# 重新加载用户修改后的
|
||||
glossary = self.glossary_manager.load_glossary()
|
||||
|
||||
return glossary
|
||||
|
||||
async def _translate_concurrently(self, chunks: List[List[Any]], manifest: ManifestManager, glossary: Dict):
|
||||
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:
|
||||
# 可以在这里加入模型分级策略
|
||||
# 例如: if len(chunk) > 50: model="fast" else: model="smart"
|
||||
results = await self.llm_client.translate_chunk(chunk, glossary, model_type="fast")
|
||||
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")
|
||||
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