feat: Release v0.10 - Modular Architecture & External Config

- Refactor codebase into src/ (preprocessing, translation, assembly)
- Add pipeline/ scripts for individual stages
- Externalize configuration to config/config.yaml
- Fix Cover Image preservation
- Update documentation and manuals
This commit is contained in:
谭凯
2026-01-31 22:49:44 +08:00
parent 9ef82393be
commit 7a93c52b42
306 changed files with 30313 additions and 1071 deletions
+9
View File
@@ -0,0 +1,9 @@
"""
测试模块初始化文件
"""
import sys
from pathlib import Path
# 添加 src 目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
占位符错误分析测试脚本
功能:
1. 使用 v3 provider (config 中配置) 翻译指定章节
2. 收集所有占位符错误
3. 输出分析报告
"""
import asyncio
import json
import re
from pathlib import Path
from collections import defaultdict
# 添加项目路径
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.epub_parser import EPUBParser
from src.epub_cleaner import EpubCleaner
from src.text_processor import TextProcessor
from src.llm_client import LLMClient
from src.format_restorer import FormatRestorer
from src.manifest_manager import ManifestManager
from src.utils import add_spacing_between_cn_and_en_num
from loguru import logger
class PlaceholderAnalyzer:
"""占位符错误分析器"""
def __init__(self, config_path: str = "config/config.json", provider: str = "openrouter"):
with open(config_path, 'r') as f:
self.config = json.load(f)
# 扁平化 provider 配置到 config['llm']
providers = self.config.get('providers', {})
if provider not in providers:
raise ValueError(f"Provider '{provider}' not found. Available: {list(providers.keys())}")
self.config['llm'] = providers[provider]
print(f"使用 LLM 供应商: {provider} ({self.config['llm'].get('base_url')})")
self.llm_client = LLMClient(self.config)
self.text_processor = TextProcessor(self.config)
self.restorer = FormatRestorer()
# 错误收集
self.errors = []
self.success_count = 0
self.total_with_placeholders = 0
async def analyze_chapter(self, epub_path: str, chapter_file: str = None):
"""
分析单个章节的占位符处理情况
Args:
epub_path: EPUB 文件路径
chapter_file: 指定章节文件名 (如 'OEBPS/c3Z.xhtml'), 不指定则使用第一个内容章节
"""
# 1. 清理 EPUB
cleaner = EpubCleaner()
cleaned_path = "cache/manifests/processed_epubs/test_cleaned.epub"
Path(cleaned_path).parent.mkdir(parents=True, exist_ok=True)
cleaner.clean_epub(epub_path, cleaned_path)
# 2. 解析
parser = EPUBParser(cleaned_path)
content_items = parser.extract_all_content_items()
# 3. 选择章节
if chapter_file:
target_items = [i for i in content_items if i['file_name'] == chapter_file]
else:
# 默认选择第一个有较多内容的章节
target_items = [i for i in content_items if len(i['content']) > 5000][:1]
if not target_items:
print("未找到目标章节")
return
target = target_items[0]
print(f"\n分析章节: {target['file_name']}")
print("=" * 60)
# 4. 提取文本
manifest = ManifestManager("cache/manifests/test_analysis_manifest.json")
manifest.init_manifest(book_id="test", metadata={})
self.text_processor.extract_to_manifest(target['content'], target['file_name'], manifest, mode="chinese")
manifest.save()
# 5. 获取待翻译项
items = manifest.get_items(status="pending")
print(f"待翻译项: {len(items)}")
# 6. 筛选有占位符的项目
items_with_ph = [i for i in items if i.placeholder_map and
any(k for k in i.placeholder_map.keys() if not k.startswith("_"))]
self.total_with_placeholders = len(items_with_ph)
print(f"含内嵌占位符的项: {self.total_with_placeholders}")
# 7. 翻译并分析
print("\n开始翻译...")
# 分块翻译
chunks = self.text_processor.create_chunks_from_manifest(manifest, mode="chinese")
for i, chunk in enumerate(chunks):
print(f" 处理块 {i+1}/{len(chunks)}...")
await self._process_chunk(chunk)
# 8. 输出分析报告
self._print_report()
async def _process_chunk(self, chunk):
"""处理单个翻译块"""
try:
results = await self.llm_client.translate_chunk(
chunk,
glossary={},
instruction="",
mode="chinese"
)
for item in chunk:
if item.global_id not in results:
continue
raw_trans = results[item.global_id]
if "[Error" in raw_trans:
continue
processed_trans = add_spacing_between_cn_and_en_num(raw_trans)
# 检查是否有内嵌占位符
inner_ph = {k: v for k, v in (item.placeholder_map or {}).items()
if not k.startswith("_")}
if inner_ph:
# 验证还原
restored, success = self.restorer.restore(processed_trans, item.placeholder_map)
if not success:
# 记录错误
expected = set(inner_ph.keys())
found = set(re.findall(r'φ(/?\\d+)φ', processed_trans))
missing = expected - found
extra = found - expected
self.errors.append({
'id': item.global_id,
'text_with_ph': item.text_with_placeholders,
'translation_with_ph': processed_trans,
'placeholder_map': inner_ph,
'missing': list(missing),
'extra': list(extra),
'expected': list(expected),
'found': list(found)
})
else:
self.success_count += 1
except Exception as e:
logger.error(f"处理块失败: {e}")
def _print_report(self):
"""输出分析报告"""
print("\n" + "=" * 80)
print("占位符错误分析报告")
print("=" * 80)
print(f"\n总计含占位符项: {self.total_with_placeholders}")
print(f"成功还原: {self.success_count}")
print(f"失败: {len(self.errors)}")
if self.total_with_placeholders > 0:
success_rate = (self.success_count / self.total_with_placeholders) * 100
print(f"成功率: {success_rate:.1f}%")
if not self.errors:
print("\n🎉 没有占位符错误!")
return
print("\n" + "-" * 80)
print("错误详情")
print("-" * 80)
# 按错误类型分组
missing_only = [e for e in self.errors if e['missing'] and not e['extra']]
extra_only = [e for e in self.errors if e['extra'] and not e['missing']]
both = [e for e in self.errors if e['missing'] and e['extra']]
print(f"\n丢失占位符: {len(missing_only)}")
print(f"多余占位符: {len(extra_only)}")
print(f"两者都有: {len(both)}")
# 详细错误列表
print("\n" + "-" * 80)
print("详细错误列表 (最多显示 10 个)")
print("-" * 80)
for i, err in enumerate(self.errors[:10]):
print(f"\n[{i+1}] ID: {err['id']}")
print(f" 原文 (带占位符): {err['text_with_ph'][:100]}...")
print(f" 译文 (带占位符): {err['translation_with_ph'][:100]}...")
print(f" 期望占位符: {err['expected']}")
print(f" 找到占位符: {err['found']}")
print(f" 丢失: {err['missing']}")
print(f" 多余: {err['extra']}")
# 模式分析
print("\n" + "-" * 80)
print("错误模式分析")
print("-" * 80)
# 分析常见的丢失模式
all_missing = []
for e in self.errors:
all_missing.extend(e['missing'])
from collections import Counter
missing_counter = Counter(all_missing)
print("\n最常丢失的占位符:")
for ph, count in missing_counter.most_common(5):
print(f" φ{ph}φ: {count}")
# 保存完整报告到文件
report_path = "cache/placeholder_error_report.json"
with open(report_path, 'w', encoding='utf-8') as f:
json.dump({
'summary': {
'total_with_placeholders': self.total_with_placeholders,
'success_count': self.success_count,
'error_count': len(self.errors),
'success_rate': (self.success_count / self.total_with_placeholders * 100) if self.total_with_placeholders > 0 else 0
},
'errors': self.errors
}, f, ensure_ascii=False, indent=2)
print(f"\n完整报告已保存到: {report_path}")
async def main():
import argparse
parser = argparse.ArgumentParser(description="占位符错误分析")
parser.add_argument("epub", help="EPUB 文件路径")
parser.add_argument("--chapter", help="指定章节文件名")
args = parser.parse_args()
analyzer = PlaceholderAnalyzer()
await analyzer.analyze_chapter(args.epub, args.chapter)
await analyzer.llm_client.close()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,5 @@
"""
文本提取实验模块
"""
__version__ = "0.1.0"
@@ -0,0 +1,233 @@
"""
缺失文本分析工具
详细分析提取器缺失的文本片段,找出根本原因
"""
import sys
from pathlib import Path
from loguru import logger
import re
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
sys.path.insert(0, str(Path(__file__).parent))
from extractors.enhanced_bs4 import EnhancedBS4Extractor
from extractors.baseline_pandoc import PandocBaseline
def analyze_missing_text(epub_path: Path, max_missing_samples: int = 20):
"""
分析缺失的文本片段
Args:
epub_path: ePub 文件路径
max_missing_samples: 最多显示的缺失样本数
"""
print(f"\n{'='*80}")
print(f"分析文件: {epub_path.name}")
print(f"{'='*80}\n")
# 1. 获取 Pandoc 基准
pandoc = PandocBaseline()
baseline_text = pandoc.extract_from_epub(str(epub_path))
if not baseline_text:
print("❌ Pandoc 提取失败")
return
print(f"Pandoc 基准长度: {len(baseline_text):,} 字符\n")
# 2. 提取器提取
book = epub.read_epub(str(epub_path))
html_docs = []
for item in book.get_items():
if item.get_type() == 9:
try:
content = item.get_content().decode('utf-8')
html_docs.append(content)
except:
continue
combined_html = "\n\n".join(html_docs)
extractor = EnhancedBS4Extractor(min_text_length=1, preserve_decorative=True) # 不过滤短文本
items = extractor.extract(combined_html)
# 分离内容和装饰性元素
content_items = [i for i in items if not i.get('is_decorative') and not i.get('is_navigation')]
decorative_items = [i for i in items if i.get('is_decorative')]
nav_items = [i for i in items if i.get('is_navigation')]
extracted_text = " ".join([item['text'] for item in content_items])
print(f"提取器统计:")
print(f" - 内容元素: {len(content_items)}")
print(f" - 装饰性元素: {len(decorative_items)}")
print(f" - 导航元素: {len(nav_items)}")
print(f" - 提取文本长度: {len(extracted_text):,} 字符\n")
# 3. 标准化文本
def normalize(text):
text = text.lower()
text = re.sub(r'[^\w\s]', ' ', text)
text = re.sub(r'\s+', ' ', text)
return text.strip()
baseline_norm = normalize(baseline_text)
extracted_norm = normalize(extracted_text)
# 4. 分词对比
baseline_words = baseline_norm.split()
extracted_words = set(extracted_norm.split())
print(f"词级别对比:")
print(f" - Pandoc 词数: {len(baseline_words):,}")
print(f" - 提取器词数: {len(extracted_words):,}")
# 5. 找出缺失的句子
print(f"\n{'='*80}")
print("分析缺失的文本片段")
print(f"{'='*80}\n")
# 将 Pandoc 文本分成句子
baseline_sentences = re.split(r'[.!?\n]+', baseline_text)
baseline_sentences = [s.strip() for s in baseline_sentences if len(s.strip()) > 10]
missing_sentences = []
for sentence in baseline_sentences:
sentence_norm = normalize(sentence)
if sentence_norm and sentence_norm not in extracted_norm:
# 检查是否有部分匹配
words = sentence_norm.split()
if len(words) > 3:
matched_words = sum(1 for w in words if w in extracted_words)
match_ratio = matched_words / len(words)
if match_ratio < 0.5: # 少于50%的词匹配,认为缺失
missing_sentences.append({
'text': sentence[:200], # 只取前200字符
'length': len(sentence),
'match_ratio': match_ratio
})
print(f"发现 {len(missing_sentences)} 个可能缺失的文本片段\n")
# 6. 分类缺失原因
print(f"{'='*80}")
print("缺失片段分类分析")
print(f"{'='*80}\n")
# 显示样本
for i, missing in enumerate(missing_sentences[:max_missing_samples], 1):
print(f"--- 缺失片段 {i} ---")
print(f"长度: {missing['length']} 字符")
print(f"匹配率: {missing['match_ratio']:.1%}")
print(f"内容: {missing['text']}")
# 尝试分析原因
text = missing['text'].lower()
reasons = []
if any(kw in text for kw in ['copyright', '©', 'isbn', 'publisher', 'published']):
reasons.append("📚 可能是版权/出版信息")
if any(kw in text for kw in ['table of contents', 'chapter', 'part', 'section']):
reasons.append("📑 可能是目录信息")
if any(kw in text for kw in ['page', 'pg', 'p.']):
reasons.append("📄 可能是页码")
if len(missing['text']) < 30:
reasons.append("📏 文本过短")
if re.match(r'^[0-9\s\-\.]+$', missing['text'].strip()):
reasons.append("🔢 纯数字")
if not reasons:
reasons.append("❓ 未知原因 - 需要进一步分析")
print(f"可能原因: {', '.join(reasons)}")
print()
if len(missing_sentences) > max_missing_samples:
print(f"... 还有 {len(missing_sentences) - max_missing_samples} 个缺失片段\n")
# 7. 统计缺失原因
print(f"{'='*80}")
print("缺失原因统计")
print(f"{'='*80}\n")
reason_counts = {
'版权/出版信息': 0,
'目录信息': 0,
'页码': 0,
'文本过短': 0,
'纯数字': 0,
'未知原因': 0
}
for missing in missing_sentences:
text = missing['text'].lower()
if any(kw in text for kw in ['copyright', '©', 'isbn', 'publisher', 'published']):
reason_counts['版权/出版信息'] += 1
elif any(kw in text for kw in ['table of contents', 'chapter', 'part', 'section']):
reason_counts['目录信息'] += 1
elif any(kw in text for kw in ['page', 'pg', 'p.']):
reason_counts['页码'] += 1
elif len(missing['text']) < 30:
reason_counts['文本过短'] += 1
elif re.match(r'^[0-9\s\-\.]+$', missing['text'].strip()):
reason_counts['纯数字'] += 1
else:
reason_counts['未知原因'] += 1
for reason, count in reason_counts.items():
if count > 0:
percentage = count / len(missing_sentences) * 100
print(f"{reason}: {count} 个 ({percentage:.1f}%)")
# 8. 建议
print(f"\n{'='*80}")
print("改进建议")
print(f"{'='*80}\n")
if reason_counts['文本过短'] > 0:
print(f"⚠️ 发现 {reason_counts['文本过短']} 个过短文本被过滤")
print(" 建议: 移除 min_text_length 限制,提取所有文本\n")
if reason_counts['版权/出版信息'] > 0:
print(f"📚 发现 {reason_counts['版权/出版信息']} 个版权/出版信息")
print(" 建议: 这些通常不需要翻译,可以保持过滤\n")
if reason_counts['目录信息'] > 0:
print(f"📑 发现 {reason_counts['目录信息']} 个目录信息")
print(" 建议: 目录通常需要翻译,检查是否被错误过滤\n")
if reason_counts['未知原因'] > 0:
print(f"❓ 发现 {reason_counts['未知原因']} 个未知原因的缺失")
print(" 建议: 需要详细分析这些片段\n")
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="WARNING") # 只显示警告和错误
# 测试一本书
test_file = "Gambling Man.epub"
epub_path = project_root / "input" / test_file
if not epub_path.exists():
print(f"文件不存在: {test_file}")
return
analyze_missing_text(epub_path, max_missing_samples=30)
if __name__ == "__main__":
main()
@@ -0,0 +1,198 @@
"""
精准缺失文本分析 - 直接对比原始 HTML
不使用 Pandoc,直接分析原始 HTML 中的文本
"""
import sys
from pathlib import Path
from loguru import logger
from bs4 import BeautifulSoup
import re
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
sys.path.insert(0, str(Path(__file__).parent))
from extractors.enhanced_bs4 import EnhancedBS4Extractor
def extract_all_text_from_html(html_content: str) -> str:
"""
从 HTML 中提取所有可见文本(包括所有元素)
这是"真正的100%"基准
"""
soup = BeautifulSoup(html_content, 'html.parser')
# 移除不可见元素
for element in soup(['script', 'style', 'meta', 'link']):
element.decompose()
# 获取所有文本
text = soup.get_text(separator=' ', strip=True)
# 清理空白
text = re.sub(r'\s+', ' ', text)
return text.strip()
def compare_extraction(epub_path: Path):
"""对比提取器与真实 HTML 文本"""
print(f"\n{'='*80}")
print(f"精准文本覆盖率分析: {epub_path.name}")
print(f"{'='*80}\n")
# 加载 ePub
book = epub.read_epub(str(epub_path))
# 提取所有 HTML 文档
html_docs = []
for item in book.get_items():
if item.get_type() == 9:
try:
content = item.get_content().decode('utf-8')
html_docs.append({
'name': item.get_name(),
'content': content
})
except:
continue
print(f"找到 {len(html_docs)} 个 HTML 文档\n")
# 逐个文档分析
total_baseline_length = 0
total_extracted_length = 0
total_missing_length = 0
missing_samples = []
for doc in html_docs:
# 基准: 所有文本
baseline_text = extract_all_text_from_html(doc['content'])
# 提取器提取
extractor = EnhancedBS4Extractor(min_text_length=1, preserve_decorative=True)
items = extractor.extract(doc['content'])
# 只统计内容元素(不包括装饰性和导航)
content_items = [i for i in items if not i.get('is_decorative') and not i.get('is_navigation')]
extracted_text = " ".join([item['text'] for item in content_items])
# 统计
baseline_len = len(baseline_text)
extracted_len = len(extracted_text)
total_baseline_length += baseline_len
total_extracted_length += extracted_len
# 找出缺失的文本
if baseline_len > 0:
coverage = extracted_len / baseline_len
if coverage < 0.99: # 覆盖率 < 99%
missing_len = baseline_len - extracted_len
total_missing_length += missing_len
# 找出具体缺失的片段
baseline_words = set(baseline_text.lower().split())
extracted_words = set(extracted_text.lower().split())
missing_words = baseline_words - extracted_words
if missing_words:
missing_samples.append({
'file': doc['name'],
'baseline_length': baseline_len,
'extracted_length': extracted_len,
'coverage': coverage,
'missing_words_count': len(missing_words),
'missing_words_sample': list(missing_words)[:20]
})
# 总体统计
overall_coverage = total_extracted_length / total_baseline_length if total_baseline_length > 0 else 0
print(f"{'='*80}")
print("总体统计")
print(f"{'='*80}\n")
print(f"基准文本总长度: {total_baseline_length:,} 字符")
print(f"提取文本总长度: {total_extracted_length:,} 字符")
print(f"缺失文本长度: {total_missing_length:,} 字符")
print(f"**覆盖率: {overall_coverage:.2%}**\n")
# 显示缺失样本
if missing_samples:
print(f"{'='*80}")
print(f"发现 {len(missing_samples)} 个文档存在缺失")
print(f"{'='*80}\n")
for i, sample in enumerate(missing_samples[:10], 1):
print(f"--- 文档 {i}: {sample['file']} ---")
print(f"基准长度: {sample['baseline_length']:,} 字符")
print(f"提取长度: {sample['extracted_length']:,} 字符")
print(f"覆盖率: {sample['coverage']:.2%}")
print(f"缺失词数: {sample['missing_words_count']}")
print(f"缺失词样本: {', '.join(sample['missing_words_sample'][:10])}")
print()
if len(missing_samples) > 10:
print(f"... 还有 {len(missing_samples) - 10} 个文档\n")
else:
print("✅ 所有文档覆盖率 ≥ 99%\n")
# 详细分析第一个缺失文档
if missing_samples:
print(f"{'='*80}")
print("详细分析第一个缺失文档")
print(f"{'='*80}\n")
first_missing = missing_samples[0]
doc_content = next(d['content'] for d in html_docs if d['name'] == first_missing['file'])
# 重新提取
baseline_text = extract_all_text_from_html(doc_content)
extractor = EnhancedBS4Extractor(min_text_length=1, preserve_decorative=True)
items = extractor.extract(doc_content)
print(f"文件: {first_missing['file']}\n")
print(f"提取了 {len(items)} 个元素:")
for item in items[:20]:
item_type = ""
if item.get('is_decorative'):
item_type = " [装饰性]"
elif item.get('is_navigation'):
item_type = " [导航]"
print(f" - [{item['tag']}] {item['text'][:60]}{item_type}")
if len(items) > 20:
print(f" ... 还有 {len(items) - 20} 个元素\n")
# 显示原始 HTML 的所有文本
print(f"\n原始 HTML 的所有文本 (前 500 字符):")
print(baseline_text[:500])
print("...\n")
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="ERROR")
test_file = "Gambling Man.epub"
epub_path = project_root / "input" / test_file
if not epub_path.exists():
print(f"文件不存在: {test_file}")
return
compare_extraction(epub_path)
if __name__ == "__main__":
main()
@@ -0,0 +1,268 @@
"""
Calibre ePub 清理器
清理 Calibre 生成的冗余 HTML 结构:
1. 将嵌套的 <div> 转为 <p>
2. 简化只有单一格式的 <span> (bold, italic)
3. 移除冗余的 calibre* 类
4. 合并相同的样式
"""
from bs4 import BeautifulSoup, Tag, NavigableString
from typing import Dict, Set
import re
from loguru import logger
class CalibreHTMLCleaner:
"""Calibre HTML 清理器"""
# 简单格式映射
SIMPLE_FORMAT_MAP = {
'bold': 'strong',
'italic': 'em',
'underline': 'u',
}
def __init__(self):
self.stats = {
'divs_to_p': 0,
'spans_simplified': 0,
'classes_removed': 0,
}
def clean(self, html_content: str) -> str:
"""
清理 HTML
Args:
html_content: 原始 HTML
Returns:
清理后的 HTML
"""
soup = BeautifulSoup(html_content, 'html.parser')
# 1. 将嵌套的 div 转为 p
self._convert_nested_divs_to_p(soup)
# 2. 简化格式 span
self._simplify_format_spans(soup)
# 3. 移除冗余的 span
self._remove_redundant_spans(soup)
# 4. 清理冗余的 calibre 类
self._clean_calibre_classes(soup)
logger.info(
f"清理完成: div→p {self.stats['divs_to_p']}, "
f"span简化 {self.stats['spans_simplified']}, "
f"类移除 {self.stats['classes_removed']}"
)
return str(soup)
def _convert_nested_divs_to_p(self, soup: BeautifulSoup):
"""
将嵌套的 div 转为 p
策略:
- 如果 div 只包含内联元素(span, em, strong等),转为 p
- 保留包含块级元素的 div
"""
inline_tags = {'span', 'em', 'strong', 'i', 'b', 'u', 'a', 'br'}
for div in soup.find_all('div'):
# 检查是否只包含内联元素
has_block_children = False
for child in div.children:
if isinstance(child, Tag):
if child.name not in inline_tags:
has_block_children = True
break
# 如果只包含内联元素,转为 p
if not has_block_children:
div.name = 'p'
self.stats['divs_to_p'] += 1
def _simplify_format_spans(self, soup: BeautifulSoup):
"""
简化只有单一格式的 span
例如:
<span class="calibre9"><span class="italic">Text</span></span>
→ <em>Text</em>
"""
for span in soup.find_all('span'):
# 检查 class 属性
classes = span.get('class', [])
if not classes:
continue
# 检查是否是简单格式
simple_format = None
for cls in classes:
for format_name, tag_name in self.SIMPLE_FORMAT_MAP.items():
if format_name in cls.lower():
simple_format = tag_name
break
if simple_format:
break
if simple_format:
# 替换为语义化标签
new_tag = soup.new_tag(simple_format)
# 复制内容
for child in list(span.children):
new_tag.append(child)
# 替换
span.replace_with(new_tag)
self.stats['spans_simplified'] += 1
def _remove_redundant_spans(self, soup: BeautifulSoup):
"""
移除冗余的 span
策略:
- 只移除完全没有属性的 span
- 保留有 class 的 span(即使是 calibre*)
- 确保不丢失任何文本
"""
removed_count = 0
# 只遍历一次,更保守
for span in soup.find_all('span'):
# 只移除完全没有属性的 span
if not span.attrs:
# 检查是否有文本内容
if span.get_text(strip=True):
# 有文本,安全地展开
span.unwrap()
removed_count += 1
self.stats['spans_removed'] = removed_count
def _remove_empty_elements(self, soup: BeautifulSoup):
"""
移除空元素
更谨慎的策略:
- 只删除完全没有内容的元素
- 保留有文本或图片的元素
"""
removed_count = 0
# 只遍历一次
for element in soup.find_all():
if isinstance(element, Tag):
# 检查是否完全为空
text = element.get_text(strip=True)
has_img = element.find('img') is not None
# 只删除既没有文本也没有图片的元素
if not text and not has_img:
element.decompose()
removed_count += 1
self.stats['empty_removed'] = removed_count
def _clean_calibre_classes(self, soup: BeautifulSoup):
"""
清理冗余的 calibre 类
策略:
- 保留有实际样式的类
- 移除纯数字的 calibre 类(如 calibre1, calibre2)
"""
for element in soup.find_all(class_=True):
classes = element.get('class', [])
if not classes:
continue
# 过滤掉纯数字的 calibre 类
new_classes = []
for cls in classes:
# 保留非 calibre 类
if not cls.startswith('calibre'):
new_classes.append(cls)
# 保留有语义的 calibre 类
elif any(keyword in cls.lower() for keyword in ['title', 'chapter', 'quote', 'note']):
new_classes.append(cls)
else:
self.stats['classes_removed'] += 1
if new_classes:
element['class'] = new_classes
else:
# 移除整个 class 属性
del element['class']
class CalibreEPUBCleaner:
"""Calibre ePub 清理器"""
def __init__(self):
self.html_cleaner = CalibreHTMLCleaner()
def clean_epub(self, epub_path: str, output_path: str):
"""
清理整个 ePub
Args:
epub_path: 输入 ePub 路径
output_path: 输出 ePub 路径
"""
from ebooklib import epub
logger.info(f"开始清理 ePub: {epub_path}")
# 加载 ePub
book = epub.read_epub(epub_path)
# 清理每个 HTML 文档
cleaned_count = 0
for item in book.get_items():
if item.get_type() != 9: # 不是 HTML
continue
try:
content = item.get_content().decode('utf-8')
except:
continue
# 清理 HTML
cleaned_html = self.html_cleaner.clean(content)
# 更新内容
item.set_content(cleaned_html.encode('utf-8'))
cleaned_count += 1
# 保存
epub.write_epub(output_path, book, {
'epub2_guide': False,
'epub3_landmark': False,
'epub3_pages': False,
'spine_direction': True,
})
logger.info(f"清理完成: 处理了 {cleaned_count} 个文档")
logger.info(f"输出: {output_path}")
if __name__ == "__main__":
import sys
from pathlib import Path
if len(sys.argv) < 3:
print("用法: python calibre_cleaner.py <input.epub> <output.epub>")
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2]
cleaner = CalibreEPUBCleaner()
cleaner.clean_epub(input_path, output_path)
@@ -0,0 +1,78 @@
"""
检查 CSS 链接保留情况
对比原始 EPUB 和清理后 EPUB 的 Head 部分
"""
import sys
from pathlib import Path
from bs4 import BeautifulSoup
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
def check_css_links(epub_path: Path):
"""检查 CSS 链接"""
print(f"\n{'='*80}")
print(f"检查 CSS 链接: {epub_path.name}")
print(f"{'='*80}\n")
book = epub.read_epub(str(epub_path))
count = 0
css_count = 0
for item in book.get_items():
if item.get_type() == 9 and 'dummy_split_002' in item.get_name():
content = item.get_content().decode('utf-8')
soup = BeautifulSoup(content, 'html.parser')
print(f"文档: {item.get_name()}\n")
# 检查 head
head = soup.find('head')
if head:
print("Head 内容:")
print(head.prettify())
links = head.find_all('link', rel='stylesheet')
if links:
print(f"\n✅ 找到 {len(links)} 个 CSS 链接")
for link in links:
print(f" - {link}")
else:
print("\n❌ 未找到 CSS 链接")
styles = head.find_all('style')
if styles:
print(f"\n✅ 找到 {len(styles)} 个 Style 标签")
for style in styles:
print(f" - {style.get_text()[:50]}...")
else:
print("\n❌ 未找到 Style 标签")
else:
print("❌ 未找到 Head 标签")
break
def main():
original_path = project_root / "input" / "On_China_Henry_Kissinger.epub"
cleaned_path = project_root / "test_output" / "On_China_cleaned.epub"
bilingual_path = project_root / "test_output" / "On_China_bilingual_test.epub"
if original_path.exists():
check_css_links(original_path)
if cleaned_path.exists():
check_css_links(cleaned_path)
if bilingual_path.exists():
check_css_links(bilingual_path)
if __name__ == "__main__":
main()
@@ -0,0 +1,124 @@
"""
创建完整的双语测试版本
使用 BS4 骨架保留方案,生成保留所有样式的双语 ePub
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from extractors.bs4_skeleton import BS4SkeletonExtractor
def create_bilingual_epub(epub_path: Path, output_path: Path, translate_toc: bool = False):
"""创建双语测试版本"""
print(f"\n{'='*80}")
print(f"创建双语测试版本: {epub_path.name}")
print(f"目录翻译: {'' if translate_toc else ''}")
print(f"{'='*80}\n")
# 加载 ePub
book = epub.read_epub(str(epub_path))
# 提取器
extractor = BS4SkeletonExtractor(translate_toc=translate_toc)
# 统计
total_items = 0
total_translate = 0
total_skip = 0
# 处理每个 HTML 文档
for item in book.get_items():
if item.get_type() != 9:
continue
try:
content = item.get_content().decode('utf-8')
except:
continue
file_name = item.get_name()
# 提取
items = extractor.extract(content, file_name)
if not items:
continue
# 统计
translate_items = [i for i in items if i['should_translate']]
skip_items = [i for i in items if not i['should_translate']]
total_items += len(items)
total_translate += len(translate_items)
total_skip += len(skip_items)
# 创建翻译映射
translation_map = {}
for i in items:
if i['should_translate']:
translation_map[i['text']] = f"{i['text']} [翻译]"
elif i['is_decorative']:
translation_map[i['text']] = f"{i['text']} [装饰]"
else:
translation_map[i['text']] = f"{i['text']} [跳过]"
# 回填
new_content = extractor.backfill(items, translation_map)
# 更新 item
item.set_content(new_content.encode('utf-8'))
# 关键修复: 使用 epub.write_epub 的选项参数
# 确保所有资源文件都被保存
epub.write_epub(str(output_path), book, {
'epub2_guide': False, # 不生成 guide
'epub3_landmark': False, # 不生成 landmark
'epub3_pages': False, # 不生成 pages
'spine_direction': True, # 保留 spine 方向
})
# 显示统计
print(f"处理统计:")
print(f" - 总元素: {total_items}")
print(f" - 翻译: {total_translate} ({total_translate/total_items*100:.1f}%)")
print(f" - 跳过: {total_skip} ({total_skip/total_items*100:.1f}%)")
print(f"\n✅ 双语测试版本已保存: {output_path}")
print(f"\n请在 ePub 阅读器中打开检查:")
print(f" 1. 样式是否完整保留 (居中、缩进、字体等)")
print(f" 2. 翻译是否正确回填")
print(f" 3. 是否有遗漏或错位")
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
# 测试文件
epub_path = project_root / "input" / "On_China_Henry_Kissinger.epub"
if not epub_path.exists():
print(f"❌ 文件不存在: {epub_path}")
return
# 输出目录
output_dir = project_root / "test_output"
output_dir.mkdir(exist_ok=True)
# 生成双语版本 (不翻译目录)
output_path = output_dir / "On_China_bilingual_skeleton.epub"
create_bilingual_epub(epub_path, output_path, translate_toc=False)
if __name__ == "__main__":
main()
@@ -0,0 +1,53 @@
"""
调试 SimpleCleaner
"""
from bs4 import BeautifulSoup, Tag
import sys
content = """<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" epub:prefix="z3998: http://www.daisy.org/z3998/2012/vocab/structure/#" lang="en" xml:lang="en">
<head/>
<body><div>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="100%" height="100%" viewbox="0 0 486 751" preserveaspectratio="none">
<image width="486" height="751" xlink:href="cover.jpeg"/>
</svg>
</div>
</body>
</html>"""
print(f"Input length: {len(content)}")
try:
soup = BeautifulSoup(content, 'html.parser')
# SimpleCleaner 逻辑复现
inline_tags = {'span', 'em', 'strong', 'i', 'b', 'u', 'a', 'br'}
divs = list(soup.find_all('div'))
print(f"Found {len(divs)} divs")
for div in divs:
has_block = any(
isinstance(c, Tag) and c.name not in inline_tags
for c in div.children
)
print(f"Div content: {div}")
print(f"Has block: {has_block}")
if not has_block:
print("Converting div to p")
div.name = 'p'
# 清理 calibre 类
elements = list(soup.find_all(class_=True))
print(f"Found {len(elements)} elements with class")
result = str(soup)
print(f"Result length: {len(result)}")
print("Result preview:")
print(result[:200])
except Exception as e:
print(f"Error: {e}")
@@ -0,0 +1,34 @@
"""
对比解析器
"""
from bs4 import BeautifulSoup
import sys
content = """<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" epub:prefix="z3998: http://www.daisy.org/z3998/2012/vocab/structure/#" lang="en" xml:lang="en">
<head/>
<body><p>Test</p></body>
</html>"""
print("--- html.parser ---")
soup = BeautifulSoup(content, 'html.parser')
print(soup.prettify())
print("\nHead:", soup.head)
try:
print("\n--- lxml-xml ---")
soup = BeautifulSoup(content, 'lxml-xml')
print(soup.prettify())
print("\nHead:", soup.head)
except Exception as e:
print(f"\nlxml-xml error: {e}")
try:
print("\n--- lxml ---")
soup = BeautifulSoup(content, 'lxml')
print(soup.prettify())
print("\nHead:", soup.head)
except Exception as e:
print(f"\nlxml error: {e}")
@@ -0,0 +1,45 @@
"""
测试诗歌格式问题
分析为什么诗歌格式会丢失
"""
from bs4 import BeautifulSoup
# 模拟诗歌 HTML
html = """
<div class="poem">
<div class="line1">War is</div>
<div class="line2">A grave affair of the state;</div>
<div class="line3">It is a place</div>
</div>
"""
print("原始 HTML:")
print(html)
print("\n" + "="*80 + "\n")
# 使用当前的提取器
soup = BeautifulSoup(html, 'html.parser')
# 查找所有 div
divs = soup.find_all('div')
print(f"找到 {len(divs)} 个 div:")
for i, div in enumerate(divs, 1):
print(f"{i}. <{div.name} class='{div.get('class')}'> {div.get_text()}")
print("\n" + "="*80 + "\n")
# 问题: 如果我们提取每个 div 的文本
texts = []
for div in divs:
if div.get('class') and 'line' in str(div.get('class')):
texts.append(div.get_text())
print(f"提取的文本: {texts}")
# 如果我们回填时只替换第一个文本节点...
print("\n问题演示:")
print("如果把所有文本替换成第一个元素的翻译,会导致:")
print(" - line1, line2, line3 都变成 'War is [翻译]'")
print(" - 其他内容丢失!")
@@ -0,0 +1,49 @@
"""
对比 ebooklib 读取内容与 zipfile 直接读取内容
"""
import sys
from pathlib import Path
from ebooklib import epub
import zipfile
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
def compare_reads(epub_path: Path):
print(f"\n对比读取: {epub_path.name}\n")
# 1. ZipFile 读取
zip_content = {}
with zipfile.ZipFile(epub_path, 'r') as zf:
for name in zf.namelist():
if 'dummy_split_002' in name:
print(f"Zip 文件名: {name}")
content = zf.read(name).decode('utf-8')
zip_content[name] = content
print(f"Zip 内容 Head 预览:\n{content[:300]}")
break
# 2. EbookLib 读取
book = epub.read_epub(str(epub_path))
for item in book.get_items():
if 'dummy_split_002' in item.get_name():
print(f"\nItem 文件名: {item.get_name()}")
content = item.get_content().decode('utf-8')
print(f"EbookLib 内容 Head 预览:\n{content[:300]}")
# 对比
if zip_content:
zip_head = zip_content[list(zip_content.keys())[0]][:300]
if zip_head != content[:300]:
print("\n⚠️ 内容不一致!")
else:
print("\n✅ 内容一致")
break
if __name__ == "__main__":
epub_path = project_root / "input" / "On_China_Henry_Kissinger.epub"
if epub_path.exists():
compare_reads(epub_path)
@@ -0,0 +1,113 @@
"""
诊断 EPUB Manifest 问题
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
def diagnose_epub(epub_path: Path):
"""诊断 EPUB 结构"""
print(f"\n{'='*80}")
print(f"诊断 EPUB: {epub_path.name}")
print(f"{'='*80}\n")
try:
book = epub.read_epub(str(epub_path))
except Exception as e:
print(f"❌ 无法读取 EPUB: {e}")
return
# 获取所有 items
all_items = list(book.get_items())
print(f"总 items 数: {len(all_items)}\n")
# 按类型分组
by_type = {}
for item in all_items:
item_type = item.get_type()
if item_type not in by_type:
by_type[item_type] = []
by_type[item_type].append(item)
print("按类型统计:")
for item_type, items in sorted(by_type.items()):
type_name = {
0: 'UNKNOWN',
1: 'IMAGE',
2: 'STYLE',
3: 'SCRIPT',
4: 'NAVIGATION',
5: 'VECTOR',
6: 'FONT',
7: 'VIDEO',
8: 'AUDIO',
9: 'DOCUMENT',
10: 'COVER'
}.get(item_type, f'TYPE_{item_type}')
print(f" {type_name}: {len(items)}")
print()
# 检查 spine
spine = book.spine
print(f"Spine 项数: {len(spine)}\n")
# 检查 titlepage
print("检查 titlepage 相关项:")
titlepage_items = [item for item in all_items if 'titlepage' in item.get_name().lower()]
if titlepage_items:
print(f" 找到 {len(titlepage_items)} 个 titlepage 项:")
for item in titlepage_items:
print(f" - {item.get_name()} (type: {item.get_type()})")
else:
print(" ❌ 未找到 titlepage 项")
print()
# 检查 spine 中的引用
print("检查 spine 引用:")
spine_refs = [ref for ref, _ in spine]
for ref in spine_refs[:10]:
# 查找对应的 item
found = False
for item in all_items:
if item.get_id() == ref:
print(f"{ref} -> {item.get_name()}")
found = True
break
if not found:
print(f"{ref} -> 未找到对应 item")
if len(spine_refs) > 10:
print(f" ... 还有 {len(spine_refs) - 10} 个引用")
print()
def main():
"""主函数"""
# 检查原始清理后的 EPUB
cleaned_path = project_root / "test_output" / "On_China_cleaned.epub"
if cleaned_path.exists():
diagnose_epub(cleaned_path)
# 检查生成的双语 EPUB
bilingual_path = project_root / "test_output" / "On_China_bilingual_test.epub"
if bilingual_path.exists():
diagnose_epub(bilingual_path)
if __name__ == "__main__":
main()
@@ -0,0 +1,130 @@
"""
DOM 路径工具模块
提供 DOM 路径的生成和查找功能,用于精准定位 HTML 元素
"""
from bs4 import BeautifulSoup, Tag
from typing import Optional
from loguru import logger
class DOMPathUtils:
"""DOM 路径工具类"""
@staticmethod
def get_dom_path(element: Tag) -> str:
"""
生成元素的唯一 DOM 路径
格式: "html>body>div[0]>p[2]"
Args:
element: BeautifulSoup Tag 对象
Returns:
DOM 路径字符串
"""
if not isinstance(element, Tag):
raise ValueError("element 必须是 BeautifulSoup Tag 对象")
path_parts = []
current = element
while current and current.name:
# 跳过非标准标签(如 BeautifulSoup 的 [document])
if current.name in ['[document]', 'html']:
current = current.parent
continue
# 获取同名兄弟元素
parent = current.parent
if parent:
siblings = [
sibling for sibling in parent.children
if isinstance(sibling, Tag) and sibling.name == current.name
]
# 找到当前元素在同名兄弟中的索引
try:
index = siblings.index(current)
except ValueError:
# 如果找不到,使用 0
index = 0
path_parts.append(f"{current.name}[{index}]")
else:
# 根元素
if current.name not in ['[document]', 'html']:
path_parts.append(current.name)
current = parent
# 反转路径(从根到叶)
return ">".join(reversed(path_parts))
@staticmethod
def find_by_path(soup: BeautifulSoup, path: str) -> Optional[Tag]:
"""
通过 DOM 路径查找元素
Args:
soup: BeautifulSoup 对象
path: DOM 路径字符串
Returns:
找到的元素,如果未找到则返回 None
"""
try:
parts = path.split(">")
current = soup
for part in parts:
# 解析标签名和索引
if "[" in part:
tag_name, index_str = part.split("[")
index = int(index_str.rstrip("]"))
else:
# 根元素可能没有索引
tag_name = part
index = 0
# 查找所有同名标签
if isinstance(current, BeautifulSoup):
# 从根开始
candidates = [current.find(tag_name)]
else:
candidates = current.find_all(tag_name, recursive=False)
if not candidates or index >= len(candidates):
logger.warning(f"路径查找失败: {path} (在 {part} 处)")
return None
current = candidates[index]
return current if isinstance(current, Tag) else None
except Exception as e:
logger.error(f"路径解析错误 {path}: {e}")
return None
@staticmethod
def validate_path(soup: BeautifulSoup, path: str, original_element: Tag) -> bool:
"""
验证路径是否能正确定位到原始元素
Args:
soup: BeautifulSoup 对象
path: DOM 路径
original_element: 原始元素
Returns:
是否验证成功
"""
found = DOMPathUtils.find_by_path(soup, path)
if found is None:
return False
# 比较元素的文本内容和标签名
return (found.name == original_element.name and
found.get_text(strip=True) == original_element.get_text(strip=True))
@@ -0,0 +1 @@
"""提取器模块"""
@@ -0,0 +1,157 @@
"""
Pandoc 基准提取器
使用 pandoc 将 ePub 转换为 Markdown,作为文本提取的参考基准
"""
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from loguru import logger
class PandocBaseline:
"""Pandoc 基准提取器"""
def __init__(self):
"""初始化,检查 pandoc 是否可用"""
self.pandoc_available = self._check_pandoc()
def _check_pandoc(self) -> bool:
"""检查 pandoc 是否安装"""
try:
result = subprocess.run(
['pandoc', '--version'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logger.info(f"Pandoc 可用: {result.stdout.split()[1]}")
return True
except Exception as e:
logger.warning(f"Pandoc 不可用: {e}")
return False
def extract_from_epub(self, epub_path: str) -> Optional[str]:
"""
使用 pandoc 从 ePub 提取文本
Args:
epub_path: ePub 文件路径
Returns:
提取的 Markdown 文本,如果失败返回 None
"""
if not self.pandoc_available:
logger.error("Pandoc 不可用,无法提取基准文本")
return None
epub_path = Path(epub_path)
if not epub_path.exists():
logger.error(f"ePub 文件不存在: {epub_path}")
return None
try:
# 创建临时输出文件
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as tmp:
tmp_path = tmp.name
# 运行 pandoc
cmd = [
'pandoc',
str(epub_path),
'-t', 'markdown',
'-o', tmp_path,
'--wrap=none' # 不自动换行
]
logger.info(f"运行 pandoc: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
logger.error(f"Pandoc 执行失败: {result.stderr}")
return None
# 读取结果
with open(tmp_path, 'r', encoding='utf-8') as f:
markdown_text = f.read()
# 清理临时文件
Path(tmp_path).unlink()
logger.info(f"Pandoc 提取成功: {len(markdown_text)} 字符")
return markdown_text
except subprocess.TimeoutExpired:
logger.error("Pandoc 执行超时")
return None
except Exception as e:
logger.error(f"Pandoc 提取失败: {e}")
return None
def extract_from_html(self, html_content: str) -> Optional[str]:
"""
使用 pandoc 从 HTML 提取文本
Args:
html_content: HTML 字符串
Returns:
提取的 Markdown 文本
"""
if not self.pandoc_available:
return None
try:
# 创建临时 HTML 文件
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as tmp_html:
tmp_html.write(html_content)
tmp_html_path = tmp_html.name
# 创建临时输出文件
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as tmp_md:
tmp_md_path = tmp_md.name
# 运行 pandoc
cmd = [
'pandoc',
tmp_html_path,
'-f', 'html',
'-t', 'markdown',
'-o', tmp_md_path,
'--wrap=none'
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
logger.error(f"Pandoc HTML 转换失败: {result.stderr}")
return None
# 读取结果
with open(tmp_md_path, 'r', encoding='utf-8') as f:
markdown_text = f.read()
# 清理临时文件
Path(tmp_html_path).unlink()
Path(tmp_md_path).unlink()
return markdown_text
except Exception as e:
logger.error(f"Pandoc HTML 提取失败: {e}")
return None
@@ -0,0 +1,207 @@
"""
优化的 BeautifulSoup 提取器
使用 DOM 路径标识系统,实现完整的文本提取和精准回填
"""
from bs4 import BeautifulSoup, Tag, NavigableString
from typing import List, Dict, Any, Set
import re
from loguru import logger
import sys
from pathlib import Path
# 添加父目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from dom_path_utils import DOMPathUtils
class BS4OptimizedExtractor:
"""优化的 BS4 提取器"""
# 标准块级标签
BLOCK_TAGS = [
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'li', 'td', 'dd', 'dt', 'figcaption',
'section', 'article', 'aside', 'header', 'footer', 'main'
]
# 导航相关的 class 关键词
NAV_KEYWORDS = [
'nav', 'navigation', 'toc', 'menu', 'header', 'footer',
'page-number', 'page-num', 'sidebar'
]
def __init__(self, min_text_length: int = 10):
"""
初始化提取器
Args:
min_text_length: 最小文本长度,过滤过短的文本
"""
self.min_text_length = min_text_length
self.path_utils = DOMPathUtils()
def extract(self, html_content: str) -> List[Dict[str, Any]]:
"""
从 HTML 中提取所有文本元素
Args:
html_content: HTML 字符串
Returns:
提取的元素列表,每个元素包含:
- path: DOM 路径
- element: BeautifulSoup Tag 对象
- text: 清理后的文本
- html: 原始 HTML
- tag: 标签名
- is_navigation: 是否是导航元素
"""
soup = BeautifulSoup(html_content, 'html.parser')
# 移除不需要的元素
for element in soup(['script', 'style', 'meta', 'link']):
element.decompose()
items = []
processed_ids = set()
# 遍历所有块级元素
for element in soup.find_all(self.BLOCK_TAGS):
elem_id = id(element)
# 避免重复处理
if elem_id in processed_ids:
continue
# 检查是否被已处理的父元素包含
if self._is_contained_in_processed(element, processed_ids):
continue
# 提取文本
text = self._clean_text(element)
# 过滤过短的文本
if len(text.strip()) < self.min_text_length:
continue
# 生成 DOM 路径
path = self.path_utils.get_dom_path(element)
# 判断是否是导航元素
is_nav = self._is_navigation_element(element)
items.append({
'path': path,
'element': element,
'text': text,
'html': str(element),
'tag': element.name,
'is_navigation': is_nav
})
processed_ids.add(elem_id)
logger.info(f"提取了 {len(items)} 个文本元素")
return items
def _is_contained_in_processed(self, element: Tag, processed_ids: Set[int]) -> bool:
"""检查元素是否被已处理的父元素包含"""
for parent in element.parents:
if isinstance(parent, Tag) and id(parent) in processed_ids:
return True
return False
def _clean_text(self, element: Tag) -> str:
"""
清理元素文本
移除:
- 脚注引用
- 仅包含数字的 span
- 多余空白
"""
# 创建副本避免修改原始元素
element_copy = BeautifulSoup(str(element), 'html.parser').find(element.name)
if not element_copy:
return ""
# 移除脚注引用
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()
# 清理残留引用标识
text = re.sub(r'(\.|。|,|)\s*(\[\d+\]|\d+)(?=\s|$)', r'\1', text)
text = re.sub(r'\s+', ' ', text)
return text
def _is_navigation_element(self, element: Tag) -> bool:
"""判断是否是导航元素"""
# 检查元素自身的 class
classes = element.get('class', [])
class_str = ' '.join(classes).lower() if isinstance(classes, list) else str(classes).lower()
if any(keyword in class_str for keyword in self.NAV_KEYWORDS):
return True
# 检查父元素的 class
parent = element.parent
if parent and isinstance(parent, Tag):
p_classes = parent.get('class', [])
p_class_str = ' '.join(p_classes).lower() if isinstance(p_classes, list) else str(p_classes).lower()
if any(keyword in p_class_str for keyword in self.NAV_KEYWORDS):
return True
return False
def backfill(self, html_content: str, translation_map: Dict[str, str]) -> str:
"""
使用 DOM 路径精准回填翻译
Args:
html_content: 原始 HTML
translation_map: {dom_path: translation} 映射
Returns:
回填后的 HTML 字符串
"""
soup = BeautifulSoup(html_content, 'html.parser')
success_count = 0
fail_count = 0
for path, translation in translation_map.items():
element = self.path_utils.find_by_path(soup, path)
if element is None:
logger.warning(f"回填失败: 未找到路径 {path}")
fail_count += 1
continue
# 创建新元素(这里简化处理,实际应根据模式创建)
new_tag = soup.new_tag(element.name)
new_tag.string = translation
# 复制属性
for attr, value in element.attrs.items():
new_tag[attr] = value
# 替换
element.replace_with(new_tag)
success_count += 1
logger.info(f"回填完成: 成功 {success_count}, 失败 {fail_count}")
return str(soup)
@@ -0,0 +1,256 @@
"""
BS4 骨架保留提取器
核心思想:
1. 提取: 保留元素引用,提取纯文本
2. 回填: 只替换文本节点,保留所有 HTML 结构和属性
"""
from bs4 import BeautifulSoup, Tag, NavigableString
from typing import List, Dict, Any
import re
from loguru import logger
class BS4SkeletonExtractor:
"""BS4 骨架保留提取器"""
SKIP_TRANSLATION_PATTERNS = [
r'index\.x?html',
r'bibliography\.x?html',
r'endnotes?\.x?html',
r'footnotes?\.x?html',
]
TOC_PATTERNS = [
r'nav\.x?html',
r'toc\.x?html',
]
OTHER_NON_CORE_PATTERNS = [
r'copyright\.x?html',
r'title\.x?html',
r'cover\.x?html',
]
BLOCK_TAGS = [
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'li', 'td', 'dd', 'dt', 'figcaption',
'section', 'article', 'aside', 'header', 'footer', 'main'
]
DECORATIVE_PATTERNS = [
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾]+$',
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾\s]+$',
]
def __init__(self, translate_toc: bool = False):
self.translate_toc = translate_toc
self.soup = None # 保存 soup 引用
def extract(self, html_content: str, file_name: str = "") -> List[Dict[str, Any]]:
"""
提取文本,保留元素引用
关键: 返回的 items 中包含对原始元素的引用
"""
self.soup = BeautifulSoup(html_content, 'html.parser')
# 移除不需要的元素
for element in self.soup(['script', 'style', 'meta', 'link']):
element.decompose()
doc_type = self._classify_document(file_name)
items = []
processed_ids = set()
for element in self.soup.find_all(self.BLOCK_TAGS):
elem_id = id(element)
if elem_id in processed_ids:
continue
if self._is_contained_in_processed(element, processed_ids):
continue
# 提取纯文本
text = element.get_text(separator=' ', strip=True)
if not text.strip():
continue
is_decorative = self._is_decorative(text)
should_translate = self._should_translate(doc_type, is_decorative)
# 关键: 保存元素引用,不是字符串!
items.append({
'element': element, # 保存元素引用
'text': text,
'should_translate': should_translate,
'doc_type': doc_type,
'is_decorative': is_decorative,
'tag': element.name
})
processed_ids.add(elem_id)
logger.info(
f"[{doc_type}] 提取 {len(items)} 个元素: "
f"翻译 {sum(1 for i in items if i['should_translate'])}"
)
return items
def backfill(self, items: List[Dict[str, Any]], translation_map: Dict[str, str]) -> str:
"""
回填翻译,保留完整的 HTML 结构
Args:
items: extract() 返回的元素列表
translation_map: {original_text: translated_text}
Returns:
回填后的完整 HTML
"""
success_count = 0
for item in items:
element = item['element']
original_text = item['text']
# 查找翻译
translation = translation_map.get(original_text)
if translation is None:
continue
# 关键: 只替换文本节点,保留所有子元素和属性
self._replace_text_only(element, translation)
success_count += 1
logger.info(f"回填完成: 成功 {success_count}/{len(translation_map)}")
# 返回完整的 HTML
return str(self.soup)
def _replace_text_only(self, element: Tag, new_text: str):
"""
只替换元素的文本内容,完全保留 HTML 结构
关键策略:
1. 只处理当前元素,不影响其他元素
2. 保留所有子元素(span, em, strong等)
3. 只替换直接的文本节点
示例:
原始: <div><span class="bold">Text</span></div>
翻译: "Text [翻译]"
结果: <div><span class="bold">Text [翻译]</span></div>
"""
from bs4 import NavigableString, Comment
# 检查元素是否有子标签
child_tags = [child for child in element.children if isinstance(child, Tag)]
if not child_tags:
# 情况1: 元素只包含文本,没有子标签
# 例如: <div>Simple text</div>
element.clear()
element.string = new_text
else:
# 情况2: 元素包含子标签
# 例如: <div><span class="bold">Text</span> more text</div>
# 策略: 找到最深层的文本节点,替换它
# 这样可以保留所有格式标签
# 递归查找最深的包含文本的元素
deepest = self._find_deepest_text_element(element)
if deepest and deepest != element:
# 在最深的元素中替换文本
deepest.clear()
deepest.string = new_text
else:
# 没有更深的元素,直接替换当前元素的所有内容
element.clear()
element.string = new_text
def _find_deepest_text_element(self, element: Tag) -> Tag:
"""
递归查找最深的包含文本的元素
返回包含实际文本内容的最深层元素
"""
from bs4 import NavigableString
# 查找所有子标签
child_tags = [child for child in element.children if isinstance(child, Tag)]
if not child_tags:
# 没有子标签,这就是最深的元素
return element
# 有子标签,递归查找
# 优先查找第一个包含文本的子标签
for child in child_tags:
if child.get_text().strip():
return self._find_deepest_text_element(child)
# 所有子标签都没有文本,返回当前元素
return element
def _classify_document(self, file_name: str) -> str:
if not file_name:
return 'core'
file_name_lower = file_name.lower()
for pattern in self.SKIP_TRANSLATION_PATTERNS:
if re.search(pattern, file_name_lower):
return 'skip'
for pattern in self.TOC_PATTERNS:
if re.search(pattern, file_name_lower):
return 'toc'
for pattern in self.OTHER_NON_CORE_PATTERNS:
if re.search(pattern, file_name_lower):
return 'other'
return 'core'
def _should_translate(self, doc_type: str, is_decorative: bool) -> bool:
if is_decorative:
return False
if doc_type == 'core':
return True
if doc_type == 'toc':
return self.translate_toc
return False
def _is_contained_in_processed(self, element: Tag, processed_ids: set) -> bool:
for parent in element.parents:
if isinstance(parent, Tag) and id(parent) in processed_ids:
return True
return False
def _is_decorative(self, text: str) -> bool:
text_stripped = text.strip()
if not text_stripped or len(text_stripped) > 20:
return False
for pattern in self.DECORATIVE_PATTERNS:
if re.match(pattern, text_stripped):
return True
unique_chars = set(text_stripped.replace(' ', ''))
if len(unique_chars) <= 3:
decorative_chars = set('*-_~•◆◇■□▪▫●○◉◎⦿⦾')
if unique_chars & decorative_chars:
return True
return False
@@ -0,0 +1,276 @@
"""
增强的提取器 - 保留装饰性元素
在原有 BS4 优化方案基础上,增强对装饰性符号和特殊元素的提取
"""
from bs4 import BeautifulSoup, Tag, NavigableString
from typing import List, Dict, Any, Set
import re
from loguru import logger
import sys
from pathlib import Path
# 添加父目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from dom_path_utils import DOMPathUtils
class EnhancedBS4Extractor:
"""增强的 BS4 提取器 - 保留装饰性元素"""
# 标准块级标签
BLOCK_TAGS = [
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'li', 'td', 'dd', 'dt', 'figcaption',
'section', 'article', 'aside', 'header', 'footer', 'main'
]
# 可能包含装饰性符号的标签
DECORATIVE_TAGS = [
'hr', # 水平线
'div', # 可能包含装饰性符号的 div
'p', # 可能只包含符号的段落
'span' # 装饰性 span
]
# 导航相关的 class 关键词
NAV_KEYWORDS = [
'nav', 'navigation', 'toc', 'menu', 'header', 'footer',
'page-number', 'page-num', 'sidebar'
]
# 装饰性符号的正则模式
DECORATIVE_PATTERNS = [
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾]+$', # 纯符号
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾\s]+$', # 符号+空白
r'^[\u2022-\u2027\u2030-\u205E]+$', # Unicode 装饰符号
]
def __init__(self, min_text_length: int = 10, preserve_decorative: bool = True):
"""
初始化提取器
Args:
min_text_length: 最小文本长度(装饰性元素不受此限制)
preserve_decorative: 是否保留装饰性元素
"""
self.min_text_length = min_text_length
self.preserve_decorative = preserve_decorative
self.path_utils = DOMPathUtils()
def extract(self, html_content: str) -> List[Dict[str, Any]]:
"""
从 HTML 中提取所有文本元素,包括装饰性元素
Args:
html_content: HTML 字符串
Returns:
提取的元素列表
"""
soup = BeautifulSoup(html_content, 'html.parser')
# 移除不需要的元素
for element in soup(['script', 'style', 'meta', 'link']):
element.decompose()
items = []
processed_ids = set()
# 1. 提取标准块级元素
for element in soup.find_all(self.BLOCK_TAGS):
elem_id = id(element)
if elem_id in processed_ids:
continue
if self._is_contained_in_processed(element, processed_ids):
continue
text = self._clean_text(element)
# 检查是否是装饰性元素
is_decorative = self._is_decorative_element(element, text)
# 过滤逻辑
if not is_decorative and len(text.strip()) < self.min_text_length:
continue
# 如果是装饰性元素但不保留,跳过
if is_decorative and not self.preserve_decorative:
continue
path = self.path_utils.get_dom_path(element)
is_nav = self._is_navigation_element(element)
items.append({
'path': path,
'element': element,
'text': text,
'html': str(element),
'tag': element.name,
'is_navigation': is_nav,
'is_decorative': is_decorative
})
processed_ids.add(elem_id)
# 2. 提取 <hr> 等纯装饰性标签
if self.preserve_decorative:
for hr in soup.find_all('hr'):
elem_id = id(hr)
if elem_id not in processed_ids:
path = self.path_utils.get_dom_path(hr)
items.append({
'path': path,
'element': hr,
'text': '---', # 用文本表示水平线
'html': str(hr),
'tag': 'hr',
'is_navigation': False,
'is_decorative': True
})
processed_ids.add(elem_id)
logger.info(f"提取了 {len(items)} 个文本元素 (包含 {sum(1 for i in items if i.get('is_decorative'))} 个装饰性元素)")
return items
def _is_decorative_element(self, element: Tag, text: str) -> bool:
"""
判断元素是否是装饰性元素
装饰性元素的特征:
1. 只包含符号(如 ***, ---, •••)
2. 文本很短但包含特殊 Unicode 符号
3. 有特定的 class (如 'separator', 'divider')
"""
# 检查 class
classes = element.get('class', [])
class_str = ' '.join(classes).lower() if isinstance(classes, list) else str(classes).lower()
decorative_classes = ['separator', 'divider', 'ornament', 'decoration', 'break']
if any(dc in class_str for dc in decorative_classes):
return True
# 检查文本是否匹配装饰性模式
text_stripped = text.strip()
if not text_stripped:
return False
for pattern in self.DECORATIVE_PATTERNS:
if re.match(pattern, text_stripped):
return True
# 检查是否只包含少量重复字符
if len(text_stripped) <= 20:
unique_chars = set(text_stripped.replace(' ', ''))
if len(unique_chars) <= 3: # 只有1-3种不同字符
# 检查是否是常见装饰符号
decorative_chars = set('*-_~•◆◇■□▪▫●○◉◎⦿⦾')
if unique_chars & decorative_chars:
return True
return False
def _is_contained_in_processed(self, element: Tag, processed_ids: Set[int]) -> bool:
"""检查元素是否被已处理的父元素包含"""
for parent in element.parents:
if isinstance(parent, Tag) and id(parent) in processed_ids:
return True
return False
def _clean_text(self, element: Tag) -> str:
"""
清理元素文本
注意: 对于装饰性元素,保留原始符号
"""
# 创建副本
element_copy = BeautifulSoup(str(element), 'html.parser').find(element.name)
if not element_copy:
return ""
# 移除脚注引用(但保留装饰性符号)
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()
# 清理残留引用标识
text = re.sub(r'(\.|。|,|)\s*(\[\d+\]|\d+)(?=\s|$)', r'\1', text)
# 对于非装饰性文本,压缩空白
# 对于装饰性文本,保留原样
if not self._is_decorative_element(element, text):
text = re.sub(r'\s+', ' ', text)
return text
def _is_navigation_element(self, element: Tag) -> bool:
"""判断是否是导航元素"""
classes = element.get('class', [])
class_str = ' '.join(classes).lower() if isinstance(classes, list) else str(classes).lower()
if any(keyword in class_str for keyword in self.NAV_KEYWORDS):
return True
parent = element.parent
if parent and isinstance(parent, Tag):
p_classes = parent.get('class', [])
p_class_str = ' '.join(p_classes).lower() if isinstance(p_classes, list) else str(p_classes).lower()
if any(keyword in p_class_str for keyword in self.NAV_KEYWORDS):
return True
return False
def backfill(self, html_content: str, translation_map: Dict[str, str]) -> str:
"""
使用 DOM 路径精准回填翻译
对于装饰性元素,保持原样不翻译
"""
soup = BeautifulSoup(html_content, 'html.parser')
success_count = 0
fail_count = 0
decorative_kept = 0
for path, translation in translation_map.items():
element = self.path_utils.find_by_path(soup, path)
if element is None:
logger.warning(f"回填失败: 未找到路径 {path}")
fail_count += 1
continue
# 检查是否是装饰性元素
original_text = element.get_text().strip()
if self._is_decorative_element(element, original_text):
# 装饰性元素保持原样
decorative_kept += 1
continue
# 创建新元素
new_tag = soup.new_tag(element.name)
new_tag.string = translation
# 复制属性
for attr, value in element.attrs.items():
new_tag[attr] = value
# 替换
element.replace_with(new_tag)
success_count += 1
logger.info(f"回填完成: 成功 {success_count}, 失败 {fail_count}, 装饰性元素保留 {decorative_kept}")
return str(soup)
@@ -0,0 +1,319 @@
"""
最终版智能提取器
明确的翻译策略:
1. 正文: 100% 翻译
2. 目录: 全翻译或全不翻译 (根据配置)
3. 索引/参考文献/尾注: 明确不翻译
4. 装饰性元素: 不翻译
"""
from bs4 import BeautifulSoup, Tag
from typing import List, Dict, Any, Set
import re
from loguru import logger
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dom_path_utils import DOMPathUtils
class FinalExtractor:
"""最终版智能提取器"""
# 明确不翻译的文档
SKIP_TRANSLATION_PATTERNS = [
r'index\.x?html', # 索引
r'bibliography\.x?html', # 参考文献
r'endnotes?\.x?html', # 尾注
r'footnotes?\.x?html', # 脚注
]
# 目录文档 (可配置是否翻译)
TOC_PATTERNS = [
r'nav\.x?html',
r'toc\.x?html',
]
# 其他非核心文档 (通常不翻译)
OTHER_NON_CORE_PATTERNS = [
r'copyright\.x?html',
r'title\.x?html',
r'cover\.x?html',
]
BLOCK_TAGS = [
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'li', 'td', 'dd', 'dt', 'figcaption',
'section', 'article', 'aside', 'header', 'footer', 'main'
]
DECORATIVE_PATTERNS = [
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾]+$',
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾\s]+$',
r'^[\u2022-\u2027\u2030-\u205E]+$',
]
def __init__(self, translate_toc: bool = False, preserve_decorative: bool = True):
"""
初始化提取器
Args:
translate_toc: 是否翻译目录 (默认不翻译)
preserve_decorative: 是否保留装饰性元素
"""
self.translate_toc = translate_toc
self.preserve_decorative = preserve_decorative
self.path_utils = DOMPathUtils()
def extract(self, html_content: str, file_name: str = "") -> List[Dict[str, Any]]:
"""
提取文本元素
每个 DOM 节点作为一个独立单位,不合并
"""
soup = BeautifulSoup(html_content, 'html.parser')
# 移除不需要的元素
for element in soup(['script', 'style', 'meta', 'link']):
element.decompose()
# 判断文档类型
doc_type = self._classify_document(file_name)
items = []
processed_ids = set()
# 遍历所有块级元素
for element in soup.find_all(self.BLOCK_TAGS):
elem_id = id(element)
if elem_id in processed_ids:
continue
if self._is_contained_in_processed(element, processed_ids):
continue
# 提取文本 (100% 完整)
text = element.get_text(separator=' ', strip=True)
if not text.strip():
continue
# 判断是否装饰性
is_decorative = self._is_decorative_element(element, text)
# 生成路径
path = self.path_utils.get_dom_path(element)
# 决定是否翻译
should_translate = self._should_translate(doc_type, is_decorative)
items.append({
'path': path,
'element': element,
'text': text,
'html': str(element),
'tag': element.name,
'is_decorative': is_decorative,
'doc_type': doc_type,
'should_translate': should_translate,
'file_name': file_name
})
processed_ids.add(elem_id)
# 添加 <hr>
if self.preserve_decorative:
for hr in soup.find_all('hr'):
elem_id = id(hr)
if elem_id not in processed_ids:
path = self.path_utils.get_dom_path(hr)
items.append({
'path': path,
'element': hr,
'text': '---',
'html': str(hr),
'tag': 'hr',
'is_decorative': True,
'doc_type': doc_type,
'should_translate': False,
'file_name': file_name
})
processed_ids.add(elem_id)
# 统计
translate_count = sum(1 for i in items if i['should_translate'])
skip_count = sum(1 for i in items if not i['should_translate'] and not i['is_decorative'])
decorative_count = sum(1 for i in items if i['is_decorative'])
logger.info(
f"[{doc_type}] 提取 {len(items)} 个元素: "
f"翻译 {translate_count}, 跳过 {skip_count}, 装饰 {decorative_count}"
)
return items
def _classify_document(self, file_name: str) -> str:
"""
分类文档类型
Returns:
'core' - 核心正文
'toc' - 目录
'skip' - 明确跳过 (索引/参考文献/尾注)
'other' - 其他非核心
"""
if not file_name:
return 'core'
file_name_lower = file_name.lower()
# 检查是否是明确跳过的
for pattern in self.SKIP_TRANSLATION_PATTERNS:
if re.search(pattern, file_name_lower):
return 'skip'
# 检查是否是目录
for pattern in self.TOC_PATTERNS:
if re.search(pattern, file_name_lower):
return 'toc'
# 检查其他非核心
for pattern in self.OTHER_NON_CORE_PATTERNS:
if re.search(pattern, file_name_lower):
return 'other'
return 'core'
def _should_translate(self, doc_type: str, is_decorative: bool) -> bool:
"""
决定是否翻译
规则:
1. 装饰性: 不翻译
2. core: 翻译
3. toc: 根据配置
4. skip: 不翻译
5. other: 不翻译
"""
if is_decorative:
return False
if doc_type == 'core':
return True
if doc_type == 'toc':
return self.translate_toc
# skip 和 other 都不翻译
return False
def _is_contained_in_processed(self, element: Tag, processed_ids: Set[int]) -> bool:
"""检查元素是否被已处理的父元素包含"""
for parent in element.parents:
if isinstance(parent, Tag) and id(parent) in processed_ids:
return True
return False
def _is_decorative_element(self, element: Tag, text: str) -> bool:
"""判断是否是装饰性元素"""
classes = element.get('class', [])
class_str = ' '.join(classes).lower() if isinstance(classes, list) else str(classes).lower()
decorative_classes = ['separator', 'divider', 'ornament', 'decoration', 'break']
if any(dc in class_str for dc in decorative_classes):
return True
text_stripped = text.strip()
if not text_stripped:
return False
for pattern in self.DECORATIVE_PATTERNS:
if re.match(pattern, text_stripped):
return True
if len(text_stripped) <= 20:
unique_chars = set(text_stripped.replace(' ', ''))
if len(unique_chars) <= 3:
decorative_chars = set('*-_~•◆◇■□▪▫●○◉◎⦿⦾')
if unique_chars & decorative_chars:
return True
return False
def backfill(self, html_content: str, translation_map: Dict[str, str]) -> str:
"""
精准回填翻译
保留原始 HTML 结构和样式,只替换文本内容
"""
soup = BeautifulSoup(html_content, 'html.parser')
success_count = 0
fail_count = 0
for path, translation in translation_map.items():
element = self.path_utils.find_by_path(soup, path)
if element is None:
logger.warning(f"回填失败: 未找到路径 {path}")
fail_count += 1
continue
# 保留原始元素结构,只替换文本节点
self._replace_text_nodes(element, translation)
success_count += 1
logger.info(f"回填完成: 成功 {success_count}, 失败 {fail_count}")
return str(soup)
def _replace_text_nodes(self, element: Tag, new_text: str):
"""
智能替换元素中的文本节点,完全保留 HTML 结构
策略:
1. 如果元素只包含纯文本(无子标签),直接替换
2. 如果元素包含子标签,递归查找并替换所有文本节点
3. 保留所有属性、class、style 等
"""
from bs4 import NavigableString
# 检查是否有子标签
child_tags = [child for child in element.children if isinstance(child, Tag)]
if not child_tags:
# 只有文本节点,直接替换
element.clear()
element.string = new_text
else:
# 有子标签,需要智能处理
# 策略: 找到所有文本节点,用新文本替换
self._replace_all_text_nodes(element, new_text)
def _replace_all_text_nodes(self, element: Tag, new_text: str):
"""
递归替换元素中的所有文本节点
保留所有子元素和属性,只替换文本内容
"""
from bs4 import NavigableString
# 收集所有文本节点
text_nodes = []
for child in element.descendants:
if isinstance(child, NavigableString) and not isinstance(child, (type(None),)):
# 跳过空白文本
if child.strip():
text_nodes.append(child)
if not text_nodes:
# 没有文本节点,直接设置
element.string = new_text
return
# 简化策略: 清空所有内容,保留结构,设置新文本
# 这会丢失内部格式,但保留外层容器的所有属性
element.clear()
element.string = new_text
@@ -0,0 +1,232 @@
"""
细粒度提取器
策略: 提取所有 <p> 元素,每个独立处理
"""
from bs4 import BeautifulSoup, Tag, NavigableString
from typing import List, Dict, Any, Tuple
import re
from loguru import logger
class FineGrainedExtractor:
"""细粒度提取器 - 每个 p 元素独立提取"""
SKIP_TRANSLATION_PATTERNS = [
r'index\.x?html',
r'bibliography\.x?html',
r'endnotes?\.x?html',
r'footnotes?\.x?html',
]
TOC_PATTERNS = [
r'nav\.x?html',
r'toc\.x?html',
]
def __init__(self, translate_toc: bool = False):
self.translate_toc = translate_toc
self.soup = None
def extract(self, html_content: str, file_name: str = "") -> List[Dict[str, Any]]:
"""
细粒度提取: 每个 <p> 和标题元素独立提取
关键: 不管嵌套,所有 <p>, h1-h6 都提取
"""
self.soup = BeautifulSoup(html_content, 'html.parser')
# 移除不需要的元素
for element in self.soup(['script', 'style', 'meta', 'link']):
element.decompose()
doc_type = self._classify_document(file_name)
items = []
# 提取所有文本块元素
target_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
for p in self.soup.find_all(target_tags):
# 提取文本
text = p.get_text(separator=' ', strip=True)
if not text.strip():
continue
# 收集所有文本节点
text_nodes = self._collect_text_nodes(p)
if not text_nodes:
continue
is_decorative = self._is_decorative(text)
should_translate = self._should_translate(doc_type, is_decorative, text)
items.append({
'element': p,
'text': text,
'text_nodes': text_nodes,
'should_translate': should_translate,
'doc_type': doc_type,
'is_decorative': is_decorative,
'tag': p.name
})
logger.info(
f"[{doc_type}] 提取 {len(items)} 个元素 (p, h1-h6): "
f"翻译 {sum(1 for i in items if i['should_translate'])}"
)
return items
def _collect_text_nodes(self, element: Tag) -> List[Tuple[NavigableString, str]]:
"""
收集元素中的所有文本节点
"""
text_nodes = []
for descendant in element.descendants:
if isinstance(descendant, NavigableString):
# 跳过注释
if isinstance(descendant, type(element)):
continue
text = str(descendant).strip()
if text:
text_nodes.append((descendant, text))
return text_nodes
def backfill(self, items: List[Dict[str, Any]], translation_map: Dict[str, str], bilingual: bool = True) -> str:
"""
回填翻译
Args:
items: 提取的元素列表
translation_map: 翻译映射 {原文: 译文}
bilingual: 是否生成双语版本 (True: 保留原文+译文, False: 只保留译文)
"""
success_count = 0
for item in items:
original_text = item['text']
element = item['element']
# 查找翻译
translation = translation_map.get(original_text)
if translation is None:
continue
if bilingual:
# 双语模式: 在元素末尾添加译文
# 创建一个新的标签用于译文 (使用相同的标签名, 如 p, h1, h2...)
translation_p = self.soup.new_tag(element.name)
# 1. 继承 class
original_classes = element.get('class', [])
if original_classes:
# 复制列表以防引用修改
translation_p['class'] = list(original_classes) + ['translation']
else:
translation_p['class'] = ['translation']
# 2. 继承 style (如果有)
original_style = element.get('style')
if original_style:
translation_p['style'] = original_style
# 3. 设置内容
translation_p.string = translation
# 在原始元素后插入译文
element.insert_after(translation_p)
else:
# 纯译文模式: 替换所有文本节点
text_nodes = item['text_nodes']
if text_nodes:
text_nodes[0][0].replace_with(translation)
for node, _ in text_nodes[1:]:
try:
node.replace_with('')
except:
pass # 节点可能已被移除
success_count += 1
logger.info(f"回填完成: 成功 {success_count}/{len(translation_map)}")
return str(self.soup)
def _classify_document(self, file_name: str) -> str:
if not file_name:
return 'core'
file_name_lower = file_name.lower()
for pattern in self.SKIP_TRANSLATION_PATTERNS:
if re.search(pattern, file_name_lower):
return 'skip'
for pattern in self.TOC_PATTERNS:
if re.search(pattern, file_name_lower):
return 'toc'
return 'core'
def _should_translate(self, doc_type: str, is_decorative: bool, text: str = "") -> bool:
if is_decorative:
return False
# 检查是否为罗马数字 (通常是章节号: I, II, III...)
if self._is_roman_numeral(text):
return False
if doc_type == 'core':
return True
if doc_type == 'toc':
return self.translate_toc
return False
def _is_roman_numeral(self, text: str) -> bool:
"""检查是否为罗马数字 (I, II, III, IV, V...)"""
text = text.strip().upper()
if not text:
return False
# 简单正则,覆盖常见直到 3999
# ^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$
# 注意: 避免匹配空字符串 (已经由 if not text 处理)
pattern = re.compile(r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$")
return bool(pattern.match(text))
def _is_decorative(self, text: str) -> bool:
text_stripped = text.strip()
if not text_stripped:
return False
# 增强: 如果只包含非字母数字字符 (标点, 符号, 分隔线等), 视为装饰性
# 这将覆盖 ***, ---, ..., _____, —— 等
if not any(c.isalnum() for c in text_stripped):
return True
if len(text_stripped) > 20:
return False
decorative_patterns = [
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾]+$',
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾\s]+$',
]
for pattern in decorative_patterns:
if re.match(pattern, text_stripped):
return True
unique_chars = set(text_stripped.replace(' ', ''))
if len(unique_chars) <= 3:
decorative_chars = set('*-_~•◆◇■□▪▫●○◉◎⦿⦾')
if unique_chars & decorative_chars:
return True
return False
@@ -0,0 +1,188 @@
"""
lxml XPath 提取器
使用 lxml 的 XPath 功能实现精准的文本提取和定位
"""
from lxml import etree, html
from typing import List, Dict, Any
from loguru import logger
import re
class LxmlXPathExtractor:
"""基于 lxml 和 XPath 的提取器"""
# 块级元素的 XPath 表达式
BLOCK_XPATH = ' | '.join([
f'//{tag}' for tag in [
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'li', 'td', 'dd', 'dt', 'figcaption',
'section', 'article', 'aside', 'header', 'footer', 'main'
]
])
NAV_KEYWORDS = [
'nav', 'navigation', 'toc', 'menu', 'header', 'footer',
'page-number', 'page-num', 'sidebar'
]
def __init__(self, min_text_length: int = 10):
"""
初始化提取器
Args:
min_text_length: 最小文本长度
"""
self.min_text_length = min_text_length
def extract(self, html_content: str) -> List[Dict[str, Any]]:
"""
从 HTML 中提取所有文本元素
Args:
html_content: HTML 字符串
Returns:
提取的元素列表
"""
try:
# 移除 XML 声明(如果存在)
import re
html_content = re.sub(r'<\?xml[^?]*\?>', '', html_content)
# 解析 HTML
tree = html.fromstring(html_content)
except Exception as e:
logger.error(f"lxml 解析失败: {e}")
return []
items = []
processed_xpaths = set()
# 使用 XPath 查找所有块级元素
try:
elements = tree.xpath(self.BLOCK_XPATH)
except Exception as e:
logger.error(f"XPath 查询失败: {e}")
return []
for element in elements:
# 获取 XPath (需要通过 ElementTree 包装)
try:
xpath = tree.getroottree().getpath(element)
except:
# 备用方案:生成简单路径
xpath = f"//{element.tag}[{elements.index(element)}]"
# 避免重复
if xpath in processed_xpaths:
continue
# 提取文本
text = self._clean_text(element)
# 过滤过短文本
if len(text.strip()) < self.min_text_length:
continue
# 判断是否是导航元素
is_nav = self._is_navigation_element(element)
# 获取 HTML
try:
element_html = etree.tostring(element, encoding='unicode')
except:
element_html = ""
items.append({
'xpath': xpath,
'text': text,
'html': element_html,
'tag': element.tag,
'is_navigation': is_nav
})
processed_xpaths.add(xpath)
logger.info(f"lxml 提取了 {len(items)} 个文本元素")
return items
def _clean_text(self, element) -> str:
"""清理元素文本"""
# lxml 的 text_content() 方法
text = element.text_content().strip()
# 清理多余空白
text = re.sub(r'\s+', ' ', text)
return text
def _is_navigation_element(self, element) -> bool:
"""判断是否是导航元素"""
# 检查 class 属性
classes = element.get('class', '')
class_str = classes.lower() if isinstance(classes, str) else ''
if any(keyword in class_str for keyword in self.NAV_KEYWORDS):
return True
# 检查父元素
parent = element.getparent()
if parent is not None:
p_classes = parent.get('class', '')
p_class_str = p_classes.lower() if isinstance(p_classes, str) else ''
if any(keyword in p_class_str for keyword in self.NAV_KEYWORDS):
return True
return False
def backfill(self, html_content: str, translation_map: Dict[str, str]) -> str:
"""
使用 XPath 精准回填翻译
Args:
html_content: 原始 HTML
translation_map: {xpath: translation} 映射
Returns:
回填后的 HTML 字符串
"""
try:
# 移除 XML 声明
import re
html_content = re.sub(r'<\?xml[^?]*\?>', '', html_content)
tree = html.fromstring(html_content)
except Exception as e:
logger.error(f"lxml 解析失败: {e}")
return html_content
success_count = 0
fail_count = 0
for xpath, translation in translation_map.items():
try:
elements = tree.xpath(xpath)
if not elements:
logger.warning(f"回填失败: 未找到 XPath {xpath}")
fail_count += 1
continue
element = elements[0]
# 清空元素内容并设置新文本
element.clear()
element.text = translation
success_count += 1
except Exception as e:
logger.error(f"回填错误 {xpath}: {e}")
fail_count += 1
logger.info(f"lxml 回填完成: 成功 {success_count}, 失败 {fail_count}")
try:
return etree.tostring(tree, encoding='unicode', method='html')
except:
return html_content
@@ -0,0 +1,267 @@
"""
真正的一比一对应提取器
核心原则:
1. 提取时: 记录每个元素的所有文本节点位置
2. 回填时: 精确替换这些文本节点,不改变任何结构
"""
from bs4 import BeautifulSoup, Tag, NavigableString
from typing import List, Dict, Any, Tuple
import re
from loguru import logger
class OneToOneExtractor:
"""一比一对应提取器"""
SKIP_TRANSLATION_PATTERNS = [
r'index\.x?html',
r'bibliography\.x?html',
r'endnotes?\.x?html',
r'footnotes?\.x?html',
]
TOC_PATTERNS = [
r'nav\.x?html',
r'toc\.x?html',
]
OTHER_NON_CORE_PATTERNS = [
r'copyright\.x?html',
r'title\.x?html',
r'cover\.x?html',
]
BLOCK_TAGS = [
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'li', 'td', 'dd', 'dt', 'figcaption',
'section', 'article', 'aside', 'header', 'footer', 'main'
]
def __init__(self, translate_toc: bool = False):
self.translate_toc = translate_toc
self.soup = None
def extract(self, html_content: str, file_name: str = "") -> List[Dict[str, Any]]:
"""
提取文本,记录精确的文本节点位置
关键改进: 不跳过嵌套元素,每个块级元素都独立提取
返回:
{
'element': 元素引用,
'text': 完整文本,
'text_nodes': [(node, text), ...], # 只包含直接子节点的文本
'should_translate': bool
}
"""
self.soup = BeautifulSoup(html_content, 'html.parser')
# 移除不需要的元素
for element in self.soup(['script', 'style', 'meta', 'link']):
element.decompose()
doc_type = self._classify_document(file_name)
items = []
# 关键: 不使用 processed_ids,每个元素都独立提取
all_elements = []
for element in self.soup.find_all(self.BLOCK_TAGS):
# 提取完整文本
full_text = element.get_text(separator=' ', strip=True)
if not full_text.strip():
continue
# 关键: 只收集当前元素的直接文本节点
# 不包括子元素中的文本节点
text_nodes = self._collect_direct_text_nodes(element)
# 如果没有直接文本节点,说明所有文本都在子元素中
# 这种情况下跳过,让子元素自己处理
if not text_nodes:
continue
all_elements.append({
'element': element,
'text': full_text,
'text_nodes': text_nodes,
'doc_type': doc_type,
})
# 过滤: 只保留叶子节点 (没有被其他提取元素包含的元素)
for elem_data in all_elements:
element = elem_data['element']
# 检查是否被其他提取元素包含
is_contained = False
for other_data in all_elements:
if other_data is elem_data:
continue
other_element = other_data['element']
# 检查 element 是否是 other_element 的子孙
if element in other_element.descendants:
is_contained = True
break
if is_contained:
continue
# 这是叶子节点,添加到结果
is_decorative = self._is_decorative(elem_data['text'])
should_translate = self._should_translate(doc_type, is_decorative)
items.append({
'element': element,
'text': elem_data['text'],
'text_nodes': elem_data['text_nodes'],
'should_translate': should_translate,
'doc_type': doc_type,
'is_decorative': is_decorative,
'tag': element.name
})
logger.info(
f"[{doc_type}] 提取 {len(items)} 个元素: "
f"翻译 {sum(1 for i in items if i['should_translate'])}"
)
return items
def _collect_direct_text_nodes(self, element: Tag) -> List[Tuple[NavigableString, str]]:
"""
收集元素的文本节点
策略:
1. 优先收集直接文本节点
2. 如果没有直接文本节点,收集所有子孙文本节点
例如:
<div>Text1 <span>Text2</span></div> → 收集 Text1 (直接)
<div><span>Text2</span></div> → 收集 Text2 (子孙)
"""
text_nodes = []
# 先尝试收集直接子节点的文本
for child in element.children:
if isinstance(child, NavigableString):
if isinstance(child, type(element)): # 跳过注释
continue
text = str(child).strip()
if text:
text_nodes.append((child, text))
# 如果有直接文本节点,返回
if text_nodes:
return text_nodes
# 否则,收集所有子孙文本节点
for descendant in element.descendants:
if isinstance(descendant, NavigableString):
if isinstance(descendant, type(element)): # 跳过注释
continue
text = str(descendant).strip()
if text:
text_nodes.append((descendant, text))
return text_nodes
def backfill(self, items: List[Dict[str, Any]], translation_map: Dict[str, str]) -> str:
"""
一比一精确回填
策略:
1. 对于每个元素,找到对应的翻译
2. 将翻译分配给所有文本节点
3. 精确替换每个文本节点
"""
success_count = 0
for item in items:
original_text = item['text']
text_nodes = item['text_nodes']
# 查找翻译
translation = translation_map.get(original_text)
if translation is None:
continue
# 一比一替换: 将翻译替换到第一个文本节点,清空其他
if text_nodes:
# 第一个文本节点替换为完整翻译
text_nodes[0][0].replace_with(translation)
# 其他文本节点清空(保留结构)
for node, _ in text_nodes[1:]:
node.replace_with('')
success_count += 1
logger.info(f"回填完成: 成功 {success_count}/{len(translation_map)}")
return str(self.soup)
def _classify_document(self, file_name: str) -> str:
if not file_name:
return 'core'
file_name_lower = file_name.lower()
for pattern in self.SKIP_TRANSLATION_PATTERNS:
if re.search(pattern, file_name_lower):
return 'skip'
for pattern in self.TOC_PATTERNS:
if re.search(pattern, file_name_lower):
return 'toc'
for pattern in self.OTHER_NON_CORE_PATTERNS:
if re.search(pattern, file_name_lower):
return 'other'
return 'core'
def _should_translate(self, doc_type: str, is_decorative: bool) -> bool:
if is_decorative:
return False
if doc_type == 'core':
return True
if doc_type == 'toc':
return self.translate_toc
return False
def _is_contained_in_processed(self, element: Tag, processed_ids: set) -> bool:
for parent in element.parents:
if isinstance(parent, Tag) and id(parent) in processed_ids:
return True
return False
def _is_decorative(self, text: str) -> bool:
text_stripped = text.strip()
if not text_stripped or len(text_stripped) > 20:
return False
decorative_patterns = [
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾]+$',
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾\s]+$',
]
for pattern in decorative_patterns:
if re.match(pattern, text_stripped):
return True
unique_chars = set(text_stripped.replace(' ', ''))
if len(unique_chars) <= 3:
decorative_chars = set('*-_~•◆◇■□▪▫●○◉◎⦿⦾')
if unique_chars & decorative_chars:
return True
return False
@@ -0,0 +1,325 @@
"""
智能分类提取器
根据文档类型和复杂度,智能决定提取策略:
- 正文: 100% 提取,必须翻译
- 非核心部分: 如果复杂度高,标记为跳过翻译
"""
from bs4 import BeautifulSoup, Tag
from typing import List, Dict, Any, Set
import re
from loguru import logger
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dom_path_utils import DOMPathUtils
class SmartExtractor:
"""智能分类提取器"""
# 非核心文档的文件名模式
NON_CORE_PATTERNS = [
r'nav\.x?html', # 目录
r'toc\.x?html', # 目录
r'index\.x?html', # 索引
r'bibliography\.x?html', # 参考文献
r'endnotes?\.x?html', # 尾注
r'footnotes?\.x?html', # 脚注
r'copyright\.x?html', # 版权页
r'title\.x?html', # 标题页
r'cover\.x?html', # 封面
]
# 块级标签
BLOCK_TAGS = [
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'li', 'td', 'dd', 'dt', 'figcaption',
'section', 'article', 'aside', 'header', 'footer', 'main'
]
# 装饰性符号模式
DECORATIVE_PATTERNS = [
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾]+$',
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾\s]+$',
r'^[\u2022-\u2027\u2030-\u205E]+$',
]
def __init__(self, preserve_decorative: bool = True):
"""
初始化提取器
Args:
preserve_decorative: 是否保留装饰性元素
"""
self.preserve_decorative = preserve_decorative
self.path_utils = DOMPathUtils()
def extract(self, html_content: str, file_name: str = "") -> List[Dict[str, Any]]:
"""
智能提取文本元素
Args:
html_content: HTML 字符串
file_name: 文件名(用于判断文档类型)
Returns:
提取的元素列表,每个元素包含:
- path: DOM 路径
- text: 文本内容
- tag: 标签名
- is_decorative: 是否装饰性
- is_core: 是否核心内容(正文)
- should_translate: 是否应该翻译
"""
soup = BeautifulSoup(html_content, 'html.parser')
# 移除不需要的元素
for element in soup(['script', 'style', 'meta', 'link']):
element.decompose()
# 判断文档类型
is_core_document = self._is_core_document(file_name)
items = []
processed_ids = set()
# 遍历所有块级元素
for element in soup.find_all(self.BLOCK_TAGS):
elem_id = id(element)
if elem_id in processed_ids:
continue
if self._is_contained_in_processed(element, processed_ids):
continue
# 提取文本(不过滤任何内容)
text = self._extract_text(element)
if not text.strip():
continue
# 判断元素类型
is_decorative = self._is_decorative_element(element, text)
# 生成路径
path = self.path_utils.get_dom_path(element)
# 决定是否翻译
should_translate = self._should_translate(
element, text, is_core_document, is_decorative
)
items.append({
'path': path,
'element': element,
'text': text,
'html': str(element),
'tag': element.name,
'is_decorative': is_decorative,
'is_core': is_core_document,
'should_translate': should_translate,
'file_name': file_name
})
processed_ids.add(elem_id)
# 添加 <hr> 等装饰性标签
if self.preserve_decorative:
for hr in soup.find_all('hr'):
elem_id = id(hr)
if elem_id not in processed_ids:
path = self.path_utils.get_dom_path(hr)
items.append({
'path': path,
'element': hr,
'text': '---',
'html': str(hr),
'tag': 'hr',
'is_decorative': True,
'is_core': is_core_document,
'should_translate': False,
'file_name': file_name
})
processed_ids.add(elem_id)
# 统计
core_count = sum(1 for i in items if i['is_core'])
translate_count = sum(1 for i in items if i['should_translate'])
decorative_count = sum(1 for i in items if i['is_decorative'])
logger.info(
f"提取了 {len(items)} 个元素 "
f"(核心: {core_count}, 需翻译: {translate_count}, 装饰性: {decorative_count})"
)
return items
def _is_core_document(self, file_name: str) -> bool:
"""
判断是否是核心文档(正文)
非核心文档包括: 目录、索引、参考文献、版权页等
"""
if not file_name:
return True # 默认认为是核心文档
file_name_lower = file_name.lower()
for pattern in self.NON_CORE_PATTERNS:
if re.search(pattern, file_name_lower):
return False
return True
def _should_translate(self, element: Tag, text: str,
is_core_document: bool, is_decorative: bool) -> bool:
"""
决定元素是否应该翻译
规则:
1. 装饰性元素: 不翻译
2. 核心文档: 全部翻译
3. 非核心文档: 根据复杂度决定
"""
# 装饰性元素不翻译
if is_decorative:
return False
# 核心文档全部翻译
if is_core_document:
return True
# 非核心文档: 检查复杂度
complexity = self._calculate_complexity(element, text)
# 复杂度阈值: 如果太复杂,不翻译
if complexity > 0.5:
logger.debug(f"非核心元素复杂度过高 ({complexity:.2f}), 跳过翻译: {text[:50]}")
return False
return True
def _calculate_complexity(self, element: Tag, text: str) -> float:
"""
计算元素的复杂度
复杂度指标:
- 嵌套深度
- 链接数量
- 数字比例
- 特殊字符比例
Returns:
0.0 - 1.0, 越高越复杂
"""
complexity_score = 0.0
# 1. 嵌套深度 (最大贡献 0.3)
depth = len(list(element.parents))
complexity_score += min(depth / 20, 0.3)
# 2. 链接数量 (最大贡献 0.3)
links = element.find_all('a')
if links:
link_ratio = len(links) / max(len(text.split()), 1)
complexity_score += min(link_ratio, 0.3)
# 3. 数字比例 (最大贡献 0.2)
digits = sum(c.isdigit() for c in text)
if text:
digit_ratio = digits / len(text)
complexity_score += min(digit_ratio * 2, 0.2)
# 4. 特殊字符比例 (最大贡献 0.2)
special_chars = sum(1 for c in text if not c.isalnum() and not c.isspace())
if text:
special_ratio = special_chars / len(text)
complexity_score += min(special_ratio * 2, 0.2)
return min(complexity_score, 1.0)
def _is_contained_in_processed(self, element: Tag, processed_ids: Set[int]) -> bool:
"""检查元素是否被已处理的父元素包含"""
for parent in element.parents:
if isinstance(parent, Tag) and id(parent) in processed_ids:
return True
return False
def _extract_text(self, element: Tag) -> str:
"""
提取元素文本 - 100% 完整提取,不过滤任何内容
注意: 这里不做任何清理,保证 100% 提取
"""
return element.get_text(separator=' ', strip=True)
def _is_decorative_element(self, element: Tag, text: str) -> bool:
"""判断是否是装饰性元素"""
# 检查 class
classes = element.get('class', [])
class_str = ' '.join(classes).lower() if isinstance(classes, list) else str(classes).lower()
decorative_classes = ['separator', 'divider', 'ornament', 'decoration', 'break']
if any(dc in class_str for dc in decorative_classes):
return True
# 检查文本模式
text_stripped = text.strip()
if not text_stripped:
return False
for pattern in self.DECORATIVE_PATTERNS:
if re.match(pattern, text_stripped):
return True
# 检查重复字符
if len(text_stripped) <= 20:
unique_chars = set(text_stripped.replace(' ', ''))
if len(unique_chars) <= 3:
decorative_chars = set('*-_~•◆◇■□▪▫●○◉◎⦿⦾')
if unique_chars & decorative_chars:
return True
return False
def backfill(self, html_content: str, translation_map: Dict[str, str]) -> str:
"""
精准回填翻译
只回填 should_translate=True 的元素
"""
soup = BeautifulSoup(html_content, 'html.parser')
success_count = 0
skip_count = 0
fail_count = 0
for path, translation in translation_map.items():
element = self.path_utils.find_by_path(soup, path)
if element is None:
logger.warning(f"回填失败: 未找到路径 {path}")
fail_count += 1
continue
# 检查是否应该翻译
# (这个信息应该在 translation_map 的构建阶段就过滤了)
# 创建新元素
new_tag = soup.new_tag(element.name)
new_tag.string = translation
# 复制属性
for attr, value in element.attrs.items():
new_tag[attr] = value
# 替换
element.replace_with(new_tag)
success_count += 1
logger.info(f"回填完成: 成功 {success_count}, 跳过 {skip_count}, 失败 {fail_count}")
return str(soup)
@@ -0,0 +1,90 @@
# 多 ePub 提取完整性测试报告
**测试时间**: 2026-01-19 12:26:59
**测试文件数**: 5
**成功**: 5/5
## 测试结果汇总
| 文件名 | 文件大小 | 提取元素 | 装饰性 | 文本长度 | 覆盖率 |
|--------|---------|---------|--------|---------|--------|
| Gambling Man.epub | 3428.3KB | 60 | 0 | 723,609 | 77.7% |
| On_China_Henry_Kissinger.epub | 924.5KB | 3602 | 32 | 1,141,726 | 87.6% |
| The World Atlas of Coffee - Fr | 20406.0KB | 1621 | 340 | 353,627 | 74.6% |
| The_Philosopher_in_the_Valley. | 4687.7KB | 54 | 15 | 524,578 | 93.9% |
| To_Explain_the_World.epub | 1756.4KB | 3564 | 105 | 782,608 | 87.9% |
## 详细分析
### Gambling Man.epub
- **HTML 文档数**: 47
- **提取元素总数**: 60
- 内容元素: 51
- 装饰性元素: 0
- 导航元素: 9
- **提取文本长度**: 723,609 字符
- **提取词数**: 118,115
- **Pandoc 基准长度**: 889,384 字符
- **覆盖率**: 77.70%
- **共同词数**: 11,607
### On_China_Henry_Kissinger.epub
- **HTML 文档数**: 144
- **提取元素总数**: 3602
- 内容元素: 3570
- 装饰性元素: 32
- 导航元素: 0
- **提取文本长度**: 1,141,726 字符
- **提取词数**: 181,503
- **Pandoc 基准长度**: 1,509,848 字符
- **覆盖率**: 87.57%
- **共同词数**: 12,834
### The World Atlas of Coffee - From Beans to Brewing - Coffees Explored, Explained and Enjoyed (James Hoffmann) (Z-Library).epub
- **HTML 文档数**: 98
- **提取元素总数**: 1621
- 内容元素: 1254
- 装饰性元素: 340
- 导航元素: 27
- **提取文本长度**: 353,627 字符
- **提取词数**: 59,885
- **Pandoc 基准长度**: 560,850 字符
- **覆盖率**: 74.62%
- **共同词数**: 5,885
### The_Philosopher_in_the_Valley.epub
- **HTML 文档数**: 22
- **提取元素总数**: 54
- 内容元素: 24
- 装饰性元素: 15
- 导航元素: 15
- **提取文本长度**: 524,578 字符
- **提取词数**: 86,566
- **Pandoc 基准长度**: 550,137 字符
- **覆盖率**: 93.95%
- **共同词数**: 9,760
### To_Explain_the_World.epub
- **HTML 文档数**: 105
- **提取元素总数**: 3564
- 内容元素: 3459
- 装饰性元素: 105
- 导航元素: 0
- **提取文本长度**: 782,608 字符
- **提取词数**: 133,415
- **Pandoc 基准长度**: 950,720 字符
- **覆盖率**: 87.90%
- **共同词数**: 9,237
## 总结
- **平均覆盖率**: 84.35%
- **总装饰性元素**: 492 个
- **提取器状态**: ⚠️ 需要优化
@@ -0,0 +1,71 @@
"""
批量运行测试: 对 input 目录下的所有 EPUB 执行清理和生成双语版本
"""
import sys
from pathlib import Path
import os
import time
from loguru import logger
# 配置路径
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
sys.path.insert(0, str(Path(__file__).parent))
# 导入功能模块
from simple_cleaner import clean_epub
from test_end_to_end import create_bilingual_epub
def run_batch():
input_dir = project_root / "input"
output_dir = project_root / "test_output"
output_dir.mkdir(exist_ok=True)
epubs = list(input_dir.glob("*.epub"))
epubs.sort() # 按文件名排序
print(f"\n{'='*80}")
print(f"批量测试开始: 共 {len(epubs)} 个文件")
print(f"{'='*80}\n")
success_count = 0
for i, epub_path in enumerate(epubs, 1):
print(f"[{i}/{len(epubs)}] 📖 处理: {epub_path.name}")
cleaned_path = output_dir / f"{epub_path.stem}_cleaned.epub"
bilingual_path = output_dir / f"{epub_path.stem}_bilingual.epub"
try:
# 1. 清理
print(" ➤ 正在清理...")
start = time.time()
# 捕获日志或只允许 ERROR? 暂时保持默认
clean_epub(str(epub_path), str(cleaned_path))
print(f" ✓ 清理完成用时: {time.time() - start:.2f}s")
# 2. 生成双语
print(" ➤ 正在生成双语版本...")
start = time.time()
create_bilingual_epub(cleaned_path, bilingual_path)
print(f" ✓ 生成完成用时: {time.time() - start:.2f}s")
print(f" ✅ 成功! 输出: {bilingual_path.name}\n")
success_count += 1
except Exception as e:
print(f" ❌ 处理失败: {e}\n")
# 不中断后续任务
continue
print(f"{'='*80}")
print(f"批量测试结束: 成功 {success_count}/{len(epubs)}")
print(f"{'='*80}\n")
if __name__ == "__main__":
# 配置 logger 只显示 WARNING 以上,以免刷屏
logger.remove()
logger.add(sys.stderr, level="WARNING")
run_batch()
@@ -0,0 +1,205 @@
"""
简化版 Calibre 清理器 - 避免复杂操作
只做最基本的清理:
1. div 转 p
2. 移除 calibre 类
"""
from bs4 import BeautifulSoup, Tag
from loguru import logger
class SimpleCleaner:
"""简化版清理器"""
def clean(self, html_content: str, item=None) -> str:
"""
清理 HTML
Args:
html_content: HTML 内容
item: EpubItem 对象(可选), 用于注册 links
"""
soup = BeautifulSoup(html_content, 'html.parser')
# 0. 提取并保留 CSS 链接 (解决 ebooklib 丢失 link 的问题)
if item:
head = soup.find('head')
if head:
# 提取 link
links = head.find_all('link', rel='stylesheet')
for link in links:
href = link.get('href')
if href:
# 检查是否已存在(避免重复)
existing_links = list(item.get_links())
exists = False
for l in existing_links:
l_href = getattr(l, 'href', None)
if l_href is None and isinstance(l, dict):
l_href = l.get('href')
if l_href == href:
exists = True
break
if not exists:
logger.debug(f"恢复 CSS 链接: {href}")
item.add_link(href=href, rel='stylesheet', type='text/css')
stats = {'divs_to_p': 0, 'classes_removed': 0}
# 1. div 转 p (只转换没有块级子元素的)
inline_tags = {'span', 'em', 'strong', 'i', 'b', 'u', 'a', 'br'}
divs = list(soup.find_all('div')) # 先收集所有div
for div in divs:
has_block = any(
isinstance(c, Tag) and c.name not in inline_tags
for c in div.children
)
if not has_block:
div.name = 'p'
stats['divs_to_p'] += 1
# 2. 清理 calibre 类 - 暂时禁用,以保留样式
# elements = list(soup.find_all(class_=True)) # 先收集
# ... (保留原注释代码)
logger.info(f"清理完成: div→p {stats['divs_to_p']}, 类移除 {stats['classes_removed']}")
return str(soup)
def clean_epub(input_path: str, output_path: str):
"""清理 ePub"""
from ebooklib import epub
import zipfile
logger.info(f"开始清理: {input_path}")
# 打开 zip 以读取原始内容
try:
input_zip = zipfile.ZipFile(input_path, 'r')
zip_files = set(input_zip.namelist())
except Exception as e:
logger.error(f"无法打开 Zip: {e}")
input_zip = None
zip_files = set()
book = epub.read_epub(input_path)
cleaner = SimpleCleaner()
count = 0
for item in book.get_items():
if item.get_type() == 9:
try:
file_name = item.get_name()
content = None
# 优先从 Zip 读取以保留 Head 信息
if input_zip and file_name in zip_files:
try:
content = input_zip.read(file_name).decode('utf-8')
except Exception as e:
logger.warning(f"Zip 读取失败 {file_name}: {e}")
# 回退到 ebooklib
if content is None:
raw_content = item.get_content()
if raw_content:
content = raw_content.decode('utf-8')
# 检查原始内容
if not content or not content.strip():
logger.warning(f"跳过空文档: {item.get_name()}")
continue
# 清理并提取信息
# 注意: 我们需要传入 item 以便 cleaner 可以注册 links
cleaned = cleaner.clean(content, item)
# 检查清理后内容
if not cleaned.strip():
logger.error(f"⚠️ 清理后内容为空: {item.get_name()} (原始长度: {len(content)})")
# 如果清理变为空,保留原始内容
cleaned = content
item.set_content(cleaned.encode('utf-8'))
count += 1
if 'titlepage' in item.get_name():
logger.info(f"Titlepage 处理完成: {len(cleaned)} chars")
except Exception as e:
logger.warning(f"清理失败 {item.get_name()}: {e}")
# 修复 TOC:补全 UID 并移除指向不存在文件的死链
def fix_and_clean_toc(toc, book):
new_toc = []
import uuid
from ebooklib.epub import Link
for item in toc:
# Case 1: (Section, Children) 元组
if isinstance(item, (tuple, list)):
section, children = item
# 递归清理子节点
cleaned_children = fix_and_clean_toc(children, book)
# 检查 Section 节点
if isinstance(section, Link):
href = section.href.split('#')[0]
# 有效性检查:目标文件必须在 manifest 中存在
if book.get_item_with_href(href):
if section.uid is None:
section.uid = f'uuid-{uuid.uuid4()}'
new_toc.append((section, cleaned_children))
else:
logger.warning(f"移除无效 TOC 节点 (目标缺失): {section.title} -> {section.href}")
# 如果父节点无效,这里选择提升子节点,还是丢弃?
# 策略:如果父节点都无效了,就把子节点提升上来(如果子节点有效)
new_toc.extend(cleaned_children)
else:
# 如果 Section 不是 Link (罕见),保留
new_toc.append((section, cleaned_children))
# Case 2: 单个 Link 节点
elif isinstance(item, Link):
href = item.href.split('#')[0]
if book.get_item_with_href(href):
if item.uid is None:
item.uid = f'uuid-{uuid.uuid4()}'
new_toc.append(item)
else:
logger.warning(f"移除无效 TOC 节点 (目标缺失): {item.title} -> {item.href}")
# Case 3: 其他 (如自定义 dict 等? 一般不会)
else:
new_toc.append(item)
return new_toc
try:
book.toc = fix_and_clean_toc(book.toc, book)
except Exception as e:
logger.warning(f"修复 TOC 失败: {e}")
import traceback
logger.warning(traceback.format_exc())
epub.write_epub(output_path, book)
logger.info(f"完成: 处理了 {count} 个文档")
if __name__ == "__main__":
import sys
if len(sys.argv) < 3:
print("用法: python simple_cleaner.py <input.epub> <output.epub>")
sys.exit(1)
clean_epub(sys.argv[1], sys.argv[2])
@@ -0,0 +1,195 @@
"""
完整回填测试
使用 On_China 书籍,模拟翻译并回填,生成双语版本供检查
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
import shutil
sys.path.insert(0, str(Path(__file__).parent))
from extractors.final_extractor import FinalExtractor
def create_bilingual_test_epub(epub_path: Path, output_path: Path, translate_toc: bool = False):
"""
创建双语测试版本
将每个元素的翻译替换为: 原文 + 标记
- 翻译元素: "原文 [翻译]"
- 跳过元素: "原文 [跳过]"
- 装饰性: "原文 [装饰]"
"""
print(f"\n{'='*80}")
print(f"创建双语测试版本: {epub_path.name}")
print(f"目录翻译: {'' if translate_toc else ''}")
print(f"{'='*80}\n")
# 加载 ePub
book = epub.read_epub(str(epub_path))
# 提取器
extractor = FinalExtractor(translate_toc=translate_toc, preserve_decorative=True)
# 统计
total_items = 0
total_translate = 0
total_skip = 0
total_decorative = 0
doc_stats = []
# 处理每个 HTML 文档
for item in book.get_items():
if item.get_type() != 9: # 只处理 ITEM_DOCUMENT
continue
try:
content = item.get_content().decode('utf-8')
except:
continue
file_name = item.get_name()
# 提取
items = extractor.extract(content, file_name)
if not items:
continue
# 统计
translate_items = [i for i in items if i['should_translate']]
skip_items = [i for i in items if not i['should_translate'] and not i['is_decorative']]
decorative_items = [i for i in items if i['is_decorative']]
total_items += len(items)
total_translate += len(translate_items)
total_skip += len(skip_items)
total_decorative += len(decorative_items)
doc_stats.append({
'file': file_name,
'doc_type': items[0]['doc_type'],
'total': len(items),
'translate': len(translate_items),
'skip': len(skip_items),
'decorative': len(decorative_items)
})
# 创建翻译映射
translation_map = {}
for i in items:
if i['should_translate']:
translation_map[i['path']] = f"{i['text']} [翻译]"
elif i['is_decorative']:
translation_map[i['path']] = f"{i['text']} [装饰]"
else:
translation_map[i['path']] = f"{i['text']} [跳过]"
# 回填
new_content = extractor.backfill(content, translation_map)
# 更新 item
item.set_content(new_content.encode('utf-8'))
# 保存新 ePub
epub.write_epub(str(output_path), book)
# 显示统计
print(f"{'='*80}")
print("处理统计")
print(f"{'='*80}\n")
print(f"总元素数: {total_items}")
print(f" - 翻译: {total_translate} ({total_translate/total_items*100:.1f}%)")
print(f" - 跳过: {total_skip} ({total_skip/total_items*100:.1f}%)")
print(f" - 装饰: {total_decorative} ({total_decorative/total_items*100:.1f}%)\n")
# 按文档类型分组统计
print(f"{'='*80}")
print("按文档类型统计")
print(f"{'='*80}\n")
doc_type_stats = {}
for stat in doc_stats:
doc_type = stat['doc_type']
if doc_type not in doc_type_stats:
doc_type_stats[doc_type] = {
'count': 0,
'total': 0,
'translate': 0,
'skip': 0,
'decorative': 0
}
doc_type_stats[doc_type]['count'] += 1
doc_type_stats[doc_type]['total'] += stat['total']
doc_type_stats[doc_type]['translate'] += stat['translate']
doc_type_stats[doc_type]['skip'] += stat['skip']
doc_type_stats[doc_type]['decorative'] += stat['decorative']
for doc_type, stats in sorted(doc_type_stats.items()):
print(f"📄 {doc_type.upper()} ({stats['count']} 个文档)")
print(f" 总元素: {stats['total']}")
print(f" 翻译: {stats['translate']} ({stats['translate']/stats['total']*100:.1f}%)")
print(f" 跳过: {stats['skip']} ({stats['skip']/stats['total']*100:.1f}%)")
print(f" 装饰: {stats['decorative']} ({stats['decorative']/stats['total']*100:.1f}%)")
print()
# 显示详细文档列表
print(f"{'='*80}")
print("详细文档列表")
print(f"{'='*80}\n")
for stat in doc_stats[:20]:
doc_type_label = stat['doc_type'].upper()
print(f"[{doc_type_label:6}] {stat['file']}")
print(f" 元素: {stat['total']:4} | 翻译: {stat['translate']:4} | 跳过: {stat['skip']:4} | 装饰: {stat['decorative']:2}")
if len(doc_stats) > 20:
print(f"\n... 还有 {len(doc_stats) - 20} 个文档\n")
print(f"\n✅ 双语测试版本已保存: {output_path}")
print(f"\n请在 ePub 阅读器中打开检查:")
print(f" - 正文应该显示: '原文 [翻译]'")
print(f" - 索引/参考文献/尾注应该显示: '原文 [跳过]'")
print(f" - 目录应该显示: '原文 [{'翻译' if translate_toc else '跳过'}]'")
print(f" - 装饰性符号应该显示: '原文 [装饰]'")
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
# 测试文件
epub_path = project_root / "input" / "On_China_Henry_Kissinger.epub"
if not epub_path.exists():
print(f"❌ 文件不存在: {epub_path}")
return
# 输出目录
output_dir = project_root / "test_output"
output_dir.mkdir(exist_ok=True)
# 测试1: 不翻译目录
output_path_1 = output_dir / "On_China_bilingual_no_toc.epub"
create_bilingual_test_epub(epub_path, output_path_1, translate_toc=False)
print(f"\n{'='*80}\n")
# 测试2: 翻译目录
output_path_2 = output_dir / "On_China_bilingual_with_toc.epub"
create_bilingual_test_epub(epub_path, output_path_2, translate_toc=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,195 @@
"""
测试 BS4 骨架保留
验证是否完整保留所有 HTML 结构、CSS 样式和属性
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
import re
sys.path.insert(0, str(Path(__file__).parent))
from extractors.bs4_skeleton import BS4SkeletonExtractor
def test_simple_html():
"""测试简单 HTML"""
html = """<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE html>
<html>
<head/>
<body>
<div class="calibre3"><span class="calibre6"><span class="bold">Table of Contents</span></span></div>
<p class="text-center" style="font-size: 18px; color: blue;">This is a centered paragraph.</p>
<blockquote class="quote" style="margin-left: 40px;">A famous quote here.</blockquote>
<h1 id="chapter1" class="chapter-title">Chapter One</h1>
</body>
</html>"""
print("\n" + "="*80)
print("简单 HTML 骨架保留测试")
print("="*80 + "\n")
# 提取
extractor = BS4SkeletonExtractor()
items = extractor.extract(html)
print(f"提取了 {len(items)} 个元素:\n")
for i, item in enumerate(items, 1):
print(f"{i}. [{item['tag']}] {item['text'][:50]}")
# 模拟翻译
translation_map = {}
for item in items:
if item['should_translate']:
translation_map[item['text']] = f"{item['text']} [翻译]"
print(f"\n待翻译: {len(translation_map)} 个元素\n")
# 回填
result_html = extractor.backfill(items, translation_map)
print("="*80)
print("回填后的 HTML:")
print("="*80 + "\n")
print(result_html)
# 验证
print("\n" + "="*80)
print("验证结果:")
print("="*80 + "\n")
checks = [
('class="calibre3"', 'class 属性'),
('class="text-center"', 'class 属性'),
('style="font-size: 18px; color: blue;"', 'style 属性'),
('style="margin-left: 40px;"', 'style 属性'),
('id="chapter1"', 'id 属性'),
('<span class="bold">', '内部格式标签'),
('<span class="calibre6">', '嵌套标签'),
]
for pattern, name in checks:
if pattern in result_html:
print(f"{name} 保留: {pattern}")
else:
print(f"{name} 丢失: {pattern}")
def test_real_epub():
"""测试真实 ePub"""
epub_path = project_root / "input" / "On_China_Henry_Kissinger.epub"
if not epub_path.exists():
print(f"\n跳过真实 ePub 测试: 文件不存在")
return
print("\n" + "="*80)
print("真实 ePub 骨架保留测试")
print("="*80 + "\n")
book = epub.read_epub(str(epub_path))
# 找第一个内容文档
for item in book.get_items():
if item.get_type() == 9 and 'dummy_split_002' in item.get_name():
content = item.get_content().decode('utf-8')
print(f"测试文件: {item.get_name()}\n")
# 统计原始 HTML 的属性
original_classes = len(re.findall(r'class="[^"]*"', content))
original_styles = len(re.findall(r'style="[^"]*"', content))
original_ids = len(re.findall(r'id="[^"]*"', content))
print(f"原始 HTML 统计:")
print(f" - class 属性: {original_classes}")
print(f" - style 属性: {original_styles}")
print(f" - id 属性: {original_ids}\n")
# 提取
extractor = BS4SkeletonExtractor()
items = extractor.extract(content, item.get_name())
print(f"提取了 {len(items)} 个元素\n")
# 显示前 3 个
for i, elem in enumerate(items[:3], 1):
print(f"元素 {i}:")
print(f" 标签: <{elem['tag']}>")
print(f" 文本: {elem['text'][:60]}...")
print()
# 模拟翻译
translation_map = {}
for elem in items:
if elem['should_translate']:
translation_map[elem['text']] = f"{elem['text']} [翻译]"
print(f"待翻译: {len(translation_map)} 个元素\n")
# 回填
result_html = extractor.backfill(items, translation_map)
# 统计回填后的属性
result_classes = len(re.findall(r'class="[^"]*"', result_html))
result_styles = len(re.findall(r'style="[^"]*"', result_html))
result_ids = len(re.findall(r'id="[^"]*"', result_html))
print("="*80)
print("回填后 HTML 统计:")
print("="*80 + "\n")
print(f" - class 属性: {result_classes}")
print(f" - style 属性: {result_styles}")
print(f" - id 属性: {result_ids}\n")
# 验证
print("="*80)
print("验证结果:")
print("="*80 + "\n")
if original_classes == result_classes:
print(f"✅ 所有 class 属性保留 ({original_classes} 个)")
else:
print(f"❌ class 属性丢失: {original_classes}{result_classes}")
if original_styles == result_styles:
print(f"✅ 所有 style 属性保留 ({original_styles} 个)")
else:
print(f"❌ style 属性丢失: {original_styles}{result_styles}")
if original_ids == result_ids:
print(f"✅ 所有 id 属性保留 ({original_ids} 个)")
else:
print(f"❌ id 属性丢失: {original_ids}{result_ids}")
# 检查翻译是否成功
if '[翻译]' in result_html:
print(f"✅ 翻译成功回填")
else:
print(f"❌ 翻译未回填")
break
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
# 测试 1: 简单 HTML
test_simple_html()
# 测试 2: 真实 ePub
test_real_epub()
if __name__ == "__main__":
main()
@@ -0,0 +1,89 @@
"""
测试 Calibre 清理器
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from loguru import logger
from calibre_cleaner import CalibreHTMLCleaner
def test_html_cleaning():
"""测试 HTML 清理"""
# 测试用例: Calibre 生成的屎山代码
html = """
<div class="calibre16">
<span class="calibre9">
<div class="calibre16">
<span class="calibre9">
<span class="italic">A ruler</span>
</span>
</div>
<div class="calibre11">
<span class="calibre9">
<span class="italic">Must never</span>
</span>
</div>
<div class="calibre11">
<span class="calibre9">
<span class="italic">Mobilize his men</span>
</span>
</div>
</span>
</div>
"""
print("\n" + "="*80)
print("Calibre HTML 清理测试")
print("="*80 + "\n")
print("原始 HTML:")
print(html)
print()
# 清理
cleaner = CalibreHTMLCleaner()
cleaned = cleaner.clean(html)
print("="*80)
print("清理后的 HTML:")
print("="*80 + "\n")
print(cleaned)
print()
# 验证
print("="*80)
print("验证:")
print("="*80 + "\n")
if '<p>' in cleaned:
print(f"✅ div 转为 p: {cleaner.stats['divs_to_p']}")
else:
print("❌ div 未转为 p")
if '<em>' in cleaned:
print(f"✅ span 简化为 em: {cleaner.stats['spans_simplified']}")
else:
print("❌ span 未简化")
if 'calibre' not in cleaned or cleaner.stats['classes_removed'] > 0:
print(f"✅ 移除 calibre 类: {cleaner.stats['classes_removed']}")
else:
print("❌ calibre 类未移除")
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
test_html_cleaning()
if __name__ == "__main__":
main()
@@ -0,0 +1,79 @@
"""
测试清理后的 ePub 提取效果
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from extractors.one_to_one import OneToOneExtractor
def test_cleaned_epub():
"""测试清理后的 ePub"""
cleaned_path = project_root / "test_output" / "On_China_cleaned.epub"
if not cleaned_path.exists():
print(f"❌ 清理后的 ePub 不存在: {cleaned_path}")
return
print("\n" + "="*80)
print("测试清理后的 ePub 提取效果")
print("="*80 + "\n")
# 加载 ePub
book = epub.read_epub(str(cleaned_path))
# 找诗歌部分
for item in book.get_items():
if item.get_type() == 9 and 'dummy_split_010' in item.get_name():
content = item.get_content().decode('utf-8')
print(f"测试文件: {item.get_name()}\n")
# 提取
extractor = OneToOneExtractor()
items = extractor.extract(content, item.get_name())
print(f"提取了 {len(items)} 个元素\n")
# 显示前10个
for i, elem in enumerate(items[:10], 1):
print(f"{i}. <{elem['tag']}> {elem['text'][:60]}...")
print(f"\n... (共 {len(items)} 个元素)")
# 检查诗歌部分
print("\n" + "="*80)
print("检查诗歌部分:")
print("="*80 + "\n")
poem_lines = [item for item in items if 'ruler' in item['text'].lower() or 'mobilize' in item['text'].lower()]
if poem_lines:
print(f"找到 {len(poem_lines)} 行诗歌:")
for i, line in enumerate(poem_lines[:5], 1):
print(f" {i}. {line['text'][:50]}")
else:
print("❌ 未找到诗歌部分")
break
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
test_cleaned_epub()
if __name__ == "__main__":
main()
@@ -0,0 +1,200 @@
"""
装饰性元素提取测试
验证增强提取器对装饰性符号的识别和保留
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from extractors.enhanced_bs4 import EnhancedBS4Extractor
from extractors.bs4_optimized import BS4OptimizedExtractor
def test_decorative_elements():
"""测试装饰性元素的识别"""
html = """
<html>
<body>
<h1>Chapter 1</h1>
<p>This is a normal paragraph.</p>
<!-- 装饰性分隔符 -->
<p class="separator">***</p>
<p>• • •</p>
<div class="divider">———</div>
<hr/>
<h2>Section 1.1</h2>
<p>Another paragraph here.</p>
<!-- 装饰性符号 -->
<p>◆◇◆</p>
<p>Final paragraph.</p>
</body>
</html>
"""
print("\n" + "="*60)
print("装饰性元素识别测试")
print("="*60)
# 标准提取器
print("\n--- 标准 BS4 提取器 ---")
standard_extractor = BS4OptimizedExtractor(min_text_length=3)
standard_items = standard_extractor.extract(html)
print(f"提取元素数: {len(standard_items)}")
for i, item in enumerate(standard_items, 1):
print(f"{i}. [{item['tag']}] {item['text'][:50]}")
# 增强提取器
print("\n--- 增强 BS4 提取器 (保留装饰性元素) ---")
enhanced_extractor = EnhancedBS4Extractor(min_text_length=10, preserve_decorative=True)
enhanced_items = enhanced_extractor.extract(html)
print(f"提取元素数: {len(enhanced_items)}")
decorative_count = 0
for i, item in enumerate(enhanced_items, 1):
decorative_flag = " [装饰性]" if item.get('is_decorative') else ""
print(f"{i}. [{item['tag']}] {item['text'][:50]}{decorative_flag}")
if item.get('is_decorative'):
decorative_count += 1
print(f"\n装饰性元素数: {decorative_count}")
# 对比
print("\n" + "-"*60)
print(f"标准提取器: {len(standard_items)} 个元素")
print(f"增强提取器: {len(enhanced_items)} 个元素 (含 {decorative_count} 个装饰性)")
print(f"差异: +{len(enhanced_items) - len(standard_items)} 个元素")
def test_real_epub_decorative():
"""测试真实 ePub 中的装饰性元素"""
epub_path = project_root / "input" / "Gambling Man.epub"
if not epub_path.exists():
print(f"\n跳过真实 ePub 测试: 文件不存在")
return
print("\n" + "="*60)
print(f"真实 ePub 装饰性元素测试")
print("="*60)
book = epub.read_epub(str(epub_path))
# 提取前几个 HTML 文档
html_docs = []
for item in book.get_items():
if item.get_type() == 9: # ITEM_DOCUMENT
try:
content = item.get_content().decode('utf-8')
html_docs.append((item.get_name(), content))
if len(html_docs) >= 5:
break
except:
continue
total_decorative = 0
for filename, html_content in html_docs:
print(f"\n--- 文件: {filename} ---")
# 标准提取
standard_extractor = BS4OptimizedExtractor()
standard_items = standard_extractor.extract(html_content)
# 增强提取
enhanced_extractor = EnhancedBS4Extractor(preserve_decorative=True)
enhanced_items = enhanced_extractor.extract(html_content)
decorative_items = [item for item in enhanced_items if item.get('is_decorative')]
total_decorative += len(decorative_items)
print(f"标准提取: {len(standard_items)} 个元素")
print(f"增强提取: {len(enhanced_items)} 个元素")
print(f"装饰性元素: {len(decorative_items)}")
if decorative_items:
print("\n装饰性元素示例:")
for item in decorative_items[:3]:
print(f" - [{item['tag']}] {item['text'][:30]}")
print("\n" + "="*60)
print(f"总计发现 {total_decorative} 个装饰性元素")
def test_decorative_preservation():
"""测试装饰性元素在回填时的保留"""
html = """
<html>
<body>
<p>First paragraph.</p>
<p>***</p>
<p>Second paragraph.</p>
</body>
</html>
"""
print("\n" + "="*60)
print("装饰性元素回填保留测试")
print("="*60)
extractor = EnhancedBS4Extractor(min_text_length=5, preserve_decorative=True)
items = extractor.extract(html)
print(f"\n提取了 {len(items)} 个元素:")
for i, item in enumerate(items, 1):
decorative_flag = " [装饰性]" if item.get('is_decorative') else ""
print(f"{i}. {item['text']}{decorative_flag}")
# 创建翻译映射(只翻译非装饰性元素)
translation_map = {}
for i, item in enumerate(items):
if not item.get('is_decorative'):
translation_map[item['path']] = f"TRANSLATED_{i}"
print(f"\n待翻译: {len(translation_map)} 个元素")
# 回填
backfilled_html = extractor.backfill(html, translation_map)
print("\n回填后的 HTML:")
from bs4 import BeautifulSoup
soup = BeautifulSoup(backfilled_html, 'html.parser')
for p in soup.find_all('p'):
print(f" <p>{p.get_text()}</p>")
# 验证装饰性元素是否保留
if '***' in backfilled_html:
print("\n✅ 装饰性符号 '***' 已保留")
else:
print("\n❌ 装饰性符号 '***' 丢失")
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
# 测试 1: 装饰性元素识别
test_decorative_elements()
# 测试 2: 真实 ePub
test_real_epub_decorative()
# 测试 3: 回填保留
test_decorative_preservation()
if __name__ == "__main__":
main()
@@ -0,0 +1,310 @@
"""
端到端测试: 清理 → 提取 → 模拟翻译 → 回填 → 生成双语 EPUB
完整流程验证
"""
import sys
from pathlib import Path
import hashlib
import os
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from extractors.fine_grained import FineGrainedExtractor
# Added MockTranslator class
class MockTranslator:
def translate(self, text: str) -> str:
"""
模拟翻译: 在文本前添加 [中文] 标记
这样可以清楚地看到哪些文本被翻译了
"""
return f"[中文] {text}"
def create_bilingual_epub(epub_path: Path, output_path: Path):
"""创建双语 EPUB"""
print(f"\n{'='*80}")
print(f"端到端测试: 生成双语 EPUB")
print(f"{'='*80}\n")
print(f"输入: {os.path.basename(epub_path)}")
print(f"输出: {os.path.basename(output_path)}")
# 1. 读取 EPUB
book = epub.read_epub(str(epub_path))
# 准备 Zip 读取以修复 CSS 链接
import zipfile
try:
input_zip = zipfile.ZipFile(epub_path, 'r')
zip_files = set(input_zip.namelist())
except Exception as e:
print(f"无法打开 Zip: {e}")
input_zip = None
zip_files = set()
extractor = FineGrainedExtractor()
translator = MockTranslator() # Using the new MockTranslator class
# Statistics variables
total_docs = 0
total_elements = 0
total_translated = 0
print("\n" + "="*80)
print("处理统计")
print("="*80 + "\n")
# 逐个文档处理
for item in book.get_items():
if item.get_type() == 9: # ITEM_DOCUMENT
try:
# 尝试从 Zip 读取原始内容
file_name = item.get_name()
content = None
if input_zip:
# 尝试精确匹配
if file_name in zip_files:
try:
content = input_zip.read(file_name).decode('utf-8')
except:
pass
else:
# 尝试模糊匹配 (处理路径前缀问题)
# 例如 item name 是 'dummy.html', zip 是 'EPUB/dummy.html'
for z_name in zip_files:
if z_name.endswith(file_name) or file_name.endswith(z_name):
try:
content = input_zip.read(z_name).decode('utf-8')
# print(f"Zip 模糊匹配: {file_name} -> {z_name}")
break
except:
pass
if content is None:
content = item.get_content().decode('utf-8')
if not content.strip():
continue
# 修复 item 的 links (如果从 Zip 读到了 link)
from bs4 import BeautifulSoup
if input_zip: # Only attempt if zipfile was successfully opened
soup = BeautifulSoup(content, 'html.parser')
head = soup.find('head')
if head:
links = head.find_all('link', rel='stylesheet')
for link in links:
href = link.get('href')
if href:
# 检查是否已存在
existing_links = list(item.get_links())
exists = False
for l in existing_links:
l_href = getattr(l, 'href', None) or (l.get('href') if isinstance(l, dict) else None)
if l_href == href:
exists = True
break
if not exists:
item.add_link(href=href, rel='stylesheet', type='text/css')
# 提取
items = extractor.extract(content, file_name)
if not items:
continue
total_docs += 1
total_elements += len(items)
# 构建翻译映射
translation_map = {}
for elem in items:
if elem['should_translate']:
original_text = elem['text']
translated_text = translator.translate(original_text)
translation_map[original_text] = translated_text
total_translated += 1
# 回填
if translation_map:
modified_html = extractor.backfill(items, translation_map)
item.set_content(modified_html.encode('utf-8'))
except Exception as e:
logger.error(f"处理失败 {item.get_name()}: {e}")
# 修复 TOC:补全 UID 并移除指向不存在文件的死链
def fix_and_clean_toc(toc, book):
new_toc = []
import uuid
from ebooklib.epub import Link
for item in toc:
if isinstance(item, (tuple, list)):
section, children = item
cleaned_children = fix_and_clean_toc(children, book)
if isinstance(section, Link):
href = section.href.split('#')[0]
if book.get_item_with_href(href):
if section.uid is None:
section.uid = f'uuid-{uuid.uuid4()}'
new_toc.append((section, cleaned_children))
else:
print(f"移除无效 TOC 节点: {section.href}")
new_toc.extend(cleaned_children)
else:
new_toc.append((section, cleaned_children))
elif isinstance(item, Link):
href = item.href.split('#')[0]
if book.get_item_with_href(href):
if item.uid is None:
item.uid = f'uuid-{uuid.uuid4()}'
new_toc.append(item)
else:
print(f"移除无效 TOC 节点: {item.href}")
else:
new_toc.append(item)
return new_toc
try:
book.toc = fix_and_clean_toc(book.toc, book)
except Exception as e:
print(f"修复 TOC 失败: {e}")
# 保存
epub.write_epub(str(output_path), book)
# 统计
print(f"{'='*80}")
print("处理统计")
print(f"{'='*80}\n")
print(f"处理文档数: {total_docs}")
print(f"提取元素数: {total_elements:,}")
print(f"翻译元素数: {total_translated:,}")
print(f"\n✅ 双语 EPUB 已生成: {output_path}\n")
def verify_bilingual_epub(epub_path: Path):
"""验证双语 EPUB"""
print(f"{'='*80}")
print(f"验证双语 EPUB")
print(f"{'='*80}\n")
book = epub.read_epub(str(epub_path))
# 检查第一个有内容的核心文档
for item in book.get_items():
if item.get_type() == 9:
content = item.get_content().decode('utf-8')
# 跳过空文档
if len(content) < 100:
continue
# 检查是否包含 [中文] 标记
if '[中文]' in content:
count = content.count('[中文]')
print(f"✅ 发现 {count} 个翻译标记\n")
# 显示部分内容
from bs4 import BeautifulSoup
soup = BeautifulSoup(content, 'html.parser')
paragraphs = soup.find_all('p')
print(f"段落总数: {len(paragraphs)}\n")
print("前10个段落:\n")
for i, p in enumerate(paragraphs[:10], 1):
text = p.get_text(strip=True)
preview = text[:80]
if len(text) > 80:
preview += "..."
# 标记译文段落
is_translation = 'translation' in p.get('class', [])
marker = " [译文]" if is_translation else " [原文]"
print(f"{i}. {preview}{marker}")
print()
# 找到一个有效的验证文件后退出循环
break
else:
print("ℹ️ 该文档无翻译标记 (可能无翻译内容),继续查找下一个...\n")
continue
else:
# 如果循环结束还没找到
print("❌ 在所有文档中均未发现翻译标记!\n")
def main():
"""主函数"""
import sys
logger.remove()
logger.add(sys.stderr, level="ERROR")
from simple_cleaner import clean_epub
input_file = "On_China_Henry_Kissinger.epub"
if len(sys.argv) > 1:
input_file = sys.argv[1]
# 推断路径
epub_name = Path(input_file).name
epub_stem = Path(input_file).stem
# 查找输入文件
input_path = Path(input_file)
if not input_path.exists():
input_path = project_root / "input" / epub_name
if not input_path.exists():
print(f"❌ 输入文件不存在: {input_path}")
# 尝试看看是不是已经在 test_output 下的 cleaned 文件
cleaned_path = project_root / "test_output" / input_file
if cleaned_path.exists() and "cleaned" in str(cleaned_path):
print(f"⚠️ 检测到已清理文件,跳过清理步骤: {cleaned_path}")
else:
return
else:
# 执行清理
cleaned_file = f"{epub_stem}_cleaned.epub"
cleaned_path = project_root / "test_output" / cleaned_file
print(f"正在清理: {input_path.name} -> {cleaned_path.name}")
try:
clean_epub(str(input_path), str(cleaned_path))
except Exception as e:
print(f"❌ 清理失败: {e}")
return
# 生成双语
bilingual_file = f"{epub_stem}_bilingual.epub"
if "cleaned" in epub_stem:
bilingual_file = epub_stem.replace("_cleaned", "_bilingual") + ".epub"
bilingual_path = project_root / "test_output" / bilingual_file
# 生成双语 EPUB
create_bilingual_epub(cleaned_path, bilingual_path)
# 验证
verify_bilingual_epub(bilingual_path)
if __name__ == "__main__":
main()
@@ -0,0 +1,368 @@
"""
文本提取实验主测试脚本
对比不同提取方案的效果,生成详细报告
"""
import sys
from pathlib import Path
from datetime import datetime
from loguru import logger
# 添加项目根目录到路径
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from extractors.bs4_optimized import BS4OptimizedExtractor
from extractors.lxml_xpath import LxmlXPathExtractor
from extractors.baseline_pandoc import PandocBaseline
from validators.completeness_check import CompletenessValidator
from validators.backfill_check import BackfillValidator
class ExtractionExperiment:
"""文本提取实验"""
def __init__(self, epub_path: str):
"""
初始化实验
Args:
epub_path: ePub 文件路径
"""
self.epub_path = Path(epub_path)
if not self.epub_path.exists():
raise FileNotFoundError(f"ePub 文件不存在: {epub_path}")
# 初始化提取器
self.bs4_extractor = BS4OptimizedExtractor()
self.lxml_extractor = LxmlXPathExtractor()
self.pandoc_baseline = PandocBaseline()
# 初始化验证器
self.completeness_validator = CompletenessValidator()
self.backfill_validator = BackfillValidator()
# 加载 ePub
self.book = epub.read_epub(str(self.epub_path))
logger.info(f"加载 ePub: {self.epub_path.name}")
def run_experiment(self) -> dict:
"""
运行完整实验
Returns:
实验结果字典
"""
results = {
'file_name': self.epub_path.name,
'timestamp': datetime.now().isoformat(),
'methods': {}
}
# 1. 获取 Pandoc 基准
logger.info("步骤 1: 提取 Pandoc 基准")
baseline_text = self.pandoc_baseline.extract_from_epub(str(self.epub_path))
if baseline_text:
results['baseline_length'] = len(baseline_text)
logger.info(f"Pandoc 基准: {len(baseline_text)} 字符")
else:
logger.warning("Pandoc 基准提取失败,将跳过覆盖率对比")
results['baseline_length'] = 0
# 2. 获取测试 HTML 内容
logger.info("步骤 2: 提取 ePub 中的 HTML 内容")
html_contents = self._extract_html_from_epub()
logger.info(f"提取了 {len(html_contents)} 个 HTML 文档")
if not html_contents:
logger.error("未找到 HTML 内容")
return results
# 合并所有 HTML(用于整体测试)
combined_html = "\n\n".join(html_contents)
# 3. 测试 BS4 优化方案
logger.info("步骤 3: 测试 BS4 优化方案")
bs4_results = self._test_extractor(
"BS4 优化方案",
self.bs4_extractor,
combined_html,
baseline_text
)
results['methods']['BS4 优化方案'] = bs4_results
# 4. 测试 lxml 方案
logger.info("步骤 4: 测试 lxml 方案")
lxml_results = self._test_extractor(
"lxml XPath 方案",
self.lxml_extractor,
combined_html,
baseline_text
)
results['methods']['lxml XPath 方案'] = lxml_results
return results
def _extract_html_from_epub(self) -> list:
"""从 ePub 中提取所有 HTML 文档"""
html_contents = []
for item in self.book.get_items():
if item.get_type() == 9: # ITEM_DOCUMENT
try:
content = item.get_content().decode('utf-8')
html_contents.append(content)
except Exception as e:
logger.warning(f"解码失败 {item.get_name()}: {e}")
return html_contents
def _test_extractor(self, method_name: str, extractor, html_content: str,
baseline_text: str = None) -> dict:
"""
测试单个提取器
Args:
method_name: 方案名称
extractor: 提取器实例
html_content: HTML 内容
baseline_text: Pandoc 基准文本
Returns:
测试结果字典
"""
results = {}
try:
# 1. 提取文本
items = extractor.extract(html_content)
results['element_count'] = len(items)
# 合并提取的文本
extracted_text = " ".join([item['text'] for item in items])
results['text_length'] = len(extracted_text)
logger.info(f"{method_name}: 提取了 {len(items)} 个元素, {len(extracted_text)} 字符")
# 2. 完整性验证
if baseline_text:
coverage = self.completeness_validator.calculate_coverage(
extracted_text, baseline_text
)
similarity = self.completeness_validator.calculate_similarity(
extracted_text, baseline_text
)
missing_segments = self.completeness_validator.find_missing_segments(
extracted_text, baseline_text
)
results['coverage'] = coverage
results['similarity'] = similarity
results['missing_segments'] = missing_segments
logger.info(f"{method_name}: 覆盖率 {coverage:.2%}, 相似度 {similarity:.2%}")
# 3. 回填验证
logger.info(f"{method_name}: 测试回填准确性")
# 位置准确性验证
success, failed = self.backfill_validator.validate_position_accuracy(
html_content, items, extractor
)
results['position_accuracy'] = success / (success + failed) if (success + failed) > 0 else 0
results['position_success'] = success
results['position_failed'] = failed
logger.info(f"{method_name}: 位置准确性 {results['position_accuracy']:.2%}")
# 模拟翻译回填
backfilled_html, backfill_results = self.backfill_validator.simulate_translation_backfill(
html_content, items, extractor
)
results['backfill_accuracy'] = backfill_results['accuracy']
results['backfill_success'] = backfill_results['success']
results['backfill_failed'] = backfill_results['failed']
logger.info(f"{method_name}: 回填准确性 {results['backfill_accuracy']:.2%}")
except Exception as e:
logger.error(f"{method_name} 测试失败: {e}")
results['error'] = str(e)
return results
def generate_report(self, results: dict) -> str:
"""
生成实验报告
Args:
results: 实验结果
Returns:
Markdown 格式的报告
"""
report = ["# 文本提取实验报告\n"]
# 基本信息
report.append("## 基本信息\n")
report.append(f"- **测试文件**: {results['file_name']}")
report.append(f"- **测试时间**: {results['timestamp']}")
report.append(f"- **Pandoc 基准长度**: {results.get('baseline_length', 0):,} 字符\n")
# 方案对比表
report.append("## 方案对比\n")
report.append("### 提取完整性\n")
report.append("| 方案 | 提取元素数 | 文本长度 | 覆盖率 | 相似度 |")
report.append("|------|-----------|---------|--------|--------|")
for method_name, method_results in results.get('methods', {}).items():
if 'error' in method_results:
report.append(f"| {method_name} | ❌ 错误 | - | - | - |")
else:
report.append(
f"| {method_name} | "
f"{method_results.get('element_count', 0):,} | "
f"{method_results.get('text_length', 0):,} | "
f"{method_results.get('coverage', 0):.2%} | "
f"{method_results.get('similarity', 0):.2%} |"
)
report.append("")
# 回填准确性
report.append("### 回填准确性\n")
report.append("| 方案 | 位置准确性 | 回填准确性 | 成功/失败 |")
report.append("|------|-----------|-----------|----------|")
for method_name, method_results in results.get('methods', {}).items():
if 'error' not in method_results:
report.append(
f"| {method_name} | "
f"{method_results.get('position_accuracy', 0):.2%} | "
f"{method_results.get('backfill_accuracy', 0):.2%} | "
f"{method_results.get('backfill_success', 0)}/{method_results.get('backfill_failed', 0)} |"
)
report.append("")
# 详细分析
report.append("## 详细分析\n")
for method_name, method_results in results.get('methods', {}).items():
report.append(f"### {method_name}\n")
if 'error' in method_results:
report.append(f"**错误**: {method_results['error']}\n")
continue
# 统计信息
report.append(f"- 提取元素数: {method_results.get('element_count', 0):,}")
report.append(f"- 文本总长度: {method_results.get('text_length', 0):,} 字符")
if 'coverage' in method_results:
report.append(f"- 覆盖率: {method_results['coverage']:.2%}")
report.append(f"- 相似度: {method_results['similarity']:.2%}")
report.append(f"- 位置准确性: {method_results.get('position_accuracy', 0):.2%}")
report.append(f"- 回填准确性: {method_results.get('backfill_accuracy', 0):.2%}")
# 缺失片段
missing = method_results.get('missing_segments', [])
if missing:
report.append(f"\n**缺失片段** ({len(missing)} 个):\n")
for i, segment in enumerate(missing[:3], 1):
report.append(f"{i}. {segment[:80]}...")
if len(missing) > 3:
report.append(f"\n... 还有 {len(missing) - 3} 个片段")
report.append("")
# 结论
report.append("## 结论\n")
# 找出最佳方案
best_method = None
best_score = 0
for method_name, method_results in results.get('methods', {}).items():
if 'error' in method_results:
continue
# 综合评分: 覆盖率 40% + 回填准确性 60%
score = (
method_results.get('coverage', 0) * 0.4 +
method_results.get('backfill_accuracy', 0) * 0.6
)
if score > best_score:
best_score = score
best_method = method_name
if best_method:
report.append(f"**推荐方案**: {best_method} (综合评分: {best_score:.2%})\n")
report.append("评分标准: 覆盖率 40% + 回填准确性 60%")
return "\n".join(report)
def main():
"""主函数"""
# 配置日志
logger.remove()
logger.add(sys.stderr, level="INFO")
# 测试文件
test_files = [
"input/Gambling Man.epub",
"input/On_China_Henry_Kissinger.epub",
"input/The World Atlas of Coffee - From Beans to Brewing - Coffees Explored, Explained and Enjoyed (James Hoffmann) (Z-Library).epub"
]
project_root = Path(__file__).parent.parent.parent
for test_file in test_files:
epub_path = project_root / test_file
if not epub_path.exists():
logger.warning(f"跳过不存在的文件: {test_file}")
continue
logger.info(f"\n{'='*60}")
logger.info(f"测试文件: {test_file}")
logger.info(f"{'='*60}\n")
try:
# 运行实验
experiment = ExtractionExperiment(str(epub_path))
results = experiment.run_experiment()
# 生成报告
report = experiment.generate_report(results)
# 保存报告
report_dir = project_root / "tests" / "extraction_experiment" / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
report_file = report_dir / f"{epub_path.stem}_report.md"
with open(report_file, 'w', encoding='utf-8') as f:
f.write(report)
logger.info(f"报告已保存: {report_file}")
# 打印摘要
print("\n" + "="*60)
print(report)
print("="*60 + "\n")
except Exception as e:
logger.error(f"实验失败: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()
@@ -0,0 +1,95 @@
"""
测试细粒度提取器
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from extractors.fine_grained import FineGrainedExtractor
def test_fine_grained():
"""测试细粒度提取"""
cleaned_path = project_root / "test_output" / "On_China_cleaned_v2.epub"
if not cleaned_path.exists():
print(f"❌ 清理后的 ePub 不存在: {cleaned_path}")
print("请先运行清理器生成 cleaned_v2.epub")
return
print("\n" + "="*80)
print("细粒度提取测试")
print("="*80 + "\n")
# 加载 ePub
book = epub.read_epub(str(cleaned_path))
# 测试第一个文档
for item in book.get_items():
if item.get_type() == 9 and 'dummy_split_010' in item.get_name():
content = item.get_content().decode('utf-8')
print(f"测试文件: {item.get_name()}\n")
# 提取
extractor = FineGrainedExtractor()
items = extractor.extract(content, item.get_name())
print(f"✅ 提取了 {len(items)} 个 <p> 元素\n")
# 显示前10个
print("前10个元素:")
for i, elem in enumerate(items[:10], 1):
print(f" {i}. {elem['text'][:60]}...")
print(f"\n... (共 {len(items)} 个)")
# 模拟翻译
translation_map = {}
for elem in items:
if elem['should_translate']:
translation_map[elem['text']] = f"{elem['text']} [翻译]"
print(f"\n待翻译: {len(translation_map)} 个元素")
# 回填
result_html = extractor.backfill(items, translation_map)
# 验证
print("\n" + "="*80)
print("验证:")
print("="*80 + "\n")
if '[翻译]' in result_html:
print("✅ 翻译成功回填")
else:
print("❌ 翻译未回填")
# 检查 <p> 数量
from bs4 import BeautifulSoup
result_soup = BeautifulSoup(result_html, 'html.parser')
result_p_count = len(result_soup.find_all('p'))
print(f"✅ 回填后 <p> 元素: {result_p_count}")
break
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
test_fine_grained()
if __name__ == "__main__":
main()
@@ -0,0 +1,177 @@
"""
测试细粒度提取器
验证 fine_grained.py 的提取效果
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from extractors.fine_grained import FineGrainedExtractor
def test_fine_grained_extraction(epub_path: Path):
"""测试细粒度提取器"""
print(f"\n{'='*80}")
print(f"细粒度提取器测试: {epub_path.name}")
print(f"{'='*80}\n")
# 加载 EPUB
book = epub.read_epub(str(epub_path))
# 统计信息
total_docs = 0
total_elements = 0
total_to_translate = 0
total_decorative = 0
doc_stats = []
# 逐个文档提取
for item in book.get_items():
if item.get_type() == 9:
try:
content = item.get_content().decode('utf-8')
file_name = item.get_name()
# 提取
extractor = FineGrainedExtractor()
items = extractor.extract(content, file_name)
if items:
total_docs += 1
total_elements += len(items)
to_translate = sum(1 for i in items if i['should_translate'])
decorative = sum(1 for i in items if i.get('is_decorative'))
total_to_translate += to_translate
total_decorative += decorative
doc_stats.append({
'name': file_name,
'total': len(items),
'to_translate': to_translate,
'decorative': decorative,
'doc_type': items[0]['doc_type'] if items else 'unknown'
})
except Exception as e:
logger.error(f"处理失败 {item.get_name()}: {e}")
# 总体统计
print(f"{'='*80}")
print("总体统计")
print(f"{'='*80}\n")
print(f"处理文档数: {total_docs}")
print(f"提取元素总数: {total_elements:,}")
print(f"需要翻译: {total_to_translate:,} ({total_to_translate/total_elements*100:.1f}%)")
print(f"装饰性元素: {total_decorative:,} ({total_decorative/total_elements*100:.1f}%)")
print()
# 按文档类型分组
core_docs = [d for d in doc_stats if d['doc_type'] == 'core']
toc_docs = [d for d in doc_stats if d['doc_type'] == 'toc']
skip_docs = [d for d in doc_stats if d['doc_type'] == 'skip']
print(f"{'='*80}")
print("按文档类型统计")
print(f"{'='*80}\n")
if core_docs:
core_elements = sum(d['total'] for d in core_docs)
core_translate = sum(d['to_translate'] for d in core_docs)
print(f"核心文档 (core): {len(core_docs)}")
print(f" - 元素数: {core_elements:,}")
print(f" - 需翻译: {core_translate:,}")
print()
if toc_docs:
toc_elements = sum(d['total'] for d in toc_docs)
toc_translate = sum(d['to_translate'] for d in toc_docs)
print(f"目录文档 (toc): {len(toc_docs)}")
print(f" - 元素数: {toc_elements:,}")
print(f" - 需翻译: {toc_translate:,}")
print()
if skip_docs:
skip_elements = sum(d['total'] for d in skip_docs)
print(f"跳过文档 (skip): {len(skip_docs)}")
print(f" - 元素数: {skip_elements:,}")
print()
# 显示部分文档详情
print(f"{'='*80}")
print("核心文档详情 (前10个)")
print(f"{'='*80}\n")
for i, doc in enumerate(core_docs[:10], 1):
print(f"{i}. {doc['name']}")
print(f" 元素: {doc['total']}, 翻译: {doc['to_translate']}, 装饰: {doc['decorative']}")
if len(core_docs) > 10:
print(f"\n... 还有 {len(core_docs) - 10} 个核心文档\n")
# 抽样显示提取内容
print(f"\n{'='*80}")
print("提取内容抽样 (第一个核心文档的前10个元素)")
print(f"{'='*80}\n")
if core_docs:
first_doc_name = core_docs[0]['name']
# 重新提取第一个文档
for item in book.get_items():
if item.get_type() == 9 and item.get_name() == first_doc_name:
content = item.get_content().decode('utf-8')
extractor = FineGrainedExtractor()
items = extractor.extract(content, first_doc_name)
print(f"文档: {first_doc_name}\n")
for i, elem in enumerate(items[:10], 1):
translate_flag = "" if elem['should_translate'] else ""
decorative_flag = " [装饰]" if elem.get('is_decorative') else ""
text_preview = elem['text'][:60]
if len(elem['text']) > 60:
text_preview += "..."
print(f"{i}. [{translate_flag}] <{elem['tag']}> {text_preview}{decorative_flag}")
if len(items) > 10:
print(f"\n... 还有 {len(items) - 10} 个元素")
break
print()
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="ERROR")
# 测试清理后的 EPUB
cleaned_file = "On_China_cleaned.epub"
cleaned_path = project_root / "test_output" / cleaned_file
if not cleaned_path.exists():
print(f"❌ 清理文件不存在: {cleaned_path}")
print(f"\n提示: 请先运行清理器:")
print(f" python tests/extraction_experiment/simple_cleaner.py \\")
print(f" 'input/On_China_Henry_Kissinger.epub' \\")
print(f" 'test_output/{cleaned_file}'")
return
test_fine_grained_extraction(cleaned_path)
if __name__ == "__main__":
main()
@@ -0,0 +1,272 @@
"""
优化的完整性测试脚本
快速对比多个 ePub 的提取完整性,与 Pandoc 基准对比
"""
import sys
from pathlib import Path
from datetime import datetime
from loguru import logger
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
sys.path.insert(0, str(Path(__file__).parent))
from extractors.enhanced_bs4 import EnhancedBS4Extractor
from extractors.baseline_pandoc import PandocBaseline
def quick_coverage_check(extracted_text: str, baseline_text: str) -> dict:
"""
快速覆盖率检查(优化版)
使用简化的词级别对比,避免复杂的相似度计算
"""
import re
# 标准化
def normalize(text):
text = text.lower()
text = re.sub(r'[^\w\s]', ' ', text)
text = re.sub(r'\s+', ' ', text)
return text.strip()
extracted_norm = normalize(extracted_text)
baseline_norm = normalize(baseline_text)
# 分词
extracted_words = set(extracted_norm.split())
baseline_words = set(baseline_norm.split())
if not baseline_words:
return {'coverage': 0.0, 'common_words': 0, 'baseline_words': 0}
common = extracted_words & baseline_words
coverage = len(common) / len(baseline_words)
return {
'coverage': coverage,
'common_words': len(common),
'baseline_words': len(baseline_words),
'extracted_words': len(extracted_words)
}
def test_single_epub(epub_path: Path, use_pandoc: bool = True) -> dict:
"""
测试单个 ePub 文件
Args:
epub_path: ePub 文件路径
use_pandoc: 是否使用 Pandoc 基准
Returns:
测试结果字典
"""
result = {
'file_name': epub_path.name,
'file_size': epub_path.stat().st_size,
'timestamp': datetime.now().isoformat()
}
try:
# 1. Pandoc 基准(可选)
baseline_text = None
if use_pandoc:
logger.info(f"提取 Pandoc 基准: {epub_path.name}")
pandoc = PandocBaseline()
baseline_text = pandoc.extract_from_epub(str(epub_path))
if baseline_text:
result['baseline_length'] = len(baseline_text)
result['baseline_words'] = len(baseline_text.split())
# 2. 加载 ePub
logger.info(f"加载 ePub: {epub_path.name}")
book = epub.read_epub(str(epub_path))
# 3. 提取 HTML 内容
html_docs = []
for item in book.get_items():
if item.get_type() == 9: # ITEM_DOCUMENT
try:
content = item.get_content().decode('utf-8')
html_docs.append(content)
except:
continue
result['html_doc_count'] = len(html_docs)
# 合并 HTML
combined_html = "\n\n".join(html_docs)
# 4. 增强提取器测试
logger.info(f"测试增强提取器")
extractor = EnhancedBS4Extractor(preserve_decorative=True)
items = extractor.extract(combined_html)
# 统计
decorative_items = [i for i in items if i.get('is_decorative')]
nav_items = [i for i in items if i.get('is_navigation')]
content_items = [i for i in items if not i.get('is_navigation') and not i.get('is_decorative')]
result['total_elements'] = len(items)
result['content_elements'] = len(content_items)
result['decorative_elements'] = len(decorative_items)
result['navigation_elements'] = len(nav_items)
# 提取的文本
extracted_text = " ".join([item['text'] for item in content_items])
result['extracted_length'] = len(extracted_text)
result['extracted_words'] = len(extracted_text.split())
# 5. 与 Pandoc 对比
if baseline_text:
coverage_result = quick_coverage_check(extracted_text, baseline_text)
result['coverage'] = coverage_result['coverage']
result['common_words'] = coverage_result['common_words']
result['status'] = 'success'
except Exception as e:
logger.error(f"测试失败 {epub_path.name}: {e}")
result['status'] = 'failed'
result['error'] = str(e)
return result
def generate_summary_report(results: list) -> str:
"""生成汇总报告"""
report = ["# 多 ePub 提取完整性测试报告\n"]
report.append(f"**测试时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
report.append(f"**测试文件数**: {len(results)}\n")
# 成功/失败统计
success_count = sum(1 for r in results if r.get('status') == 'success')
report.append(f"**成功**: {success_count}/{len(results)}\n")
# 汇总表
report.append("## 测试结果汇总\n")
report.append("| 文件名 | 文件大小 | 提取元素 | 装饰性 | 文本长度 | 覆盖率 |")
report.append("|--------|---------|---------|--------|---------|--------|")
for r in results:
if r.get('status') != 'success':
report.append(f"| {r['file_name'][:30]} | - | ❌ 失败 | - | - | - |")
continue
file_size = f"{r.get('file_size', 0) / 1024:.1f}KB"
total_elem = r.get('total_elements', 0)
decorative = r.get('decorative_elements', 0)
text_len = f"{r.get('extracted_length', 0):,}"
coverage = r.get('coverage', 0)
coverage_str = f"{coverage:.1%}" if coverage > 0 else "N/A"
report.append(
f"| {r['file_name'][:30]} | {file_size} | {total_elem} | {decorative} | {text_len} | {coverage_str} |"
)
report.append("")
# 详细分析
report.append("## 详细分析\n")
for r in results:
if r.get('status') != 'success':
continue
report.append(f"### {r['file_name']}\n")
report.append(f"- **HTML 文档数**: {r.get('html_doc_count', 0)}")
report.append(f"- **提取元素总数**: {r.get('total_elements', 0)}")
report.append(f" - 内容元素: {r.get('content_elements', 0)}")
report.append(f" - 装饰性元素: {r.get('decorative_elements', 0)}")
report.append(f" - 导航元素: {r.get('navigation_elements', 0)}")
report.append(f"- **提取文本长度**: {r.get('extracted_length', 0):,} 字符")
report.append(f"- **提取词数**: {r.get('extracted_words', 0):,}")
if 'baseline_length' in r:
report.append(f"- **Pandoc 基准长度**: {r.get('baseline_length', 0):,} 字符")
report.append(f"- **覆盖率**: {r.get('coverage', 0):.2%}")
report.append(f"- **共同词数**: {r.get('common_words', 0):,}")
report.append("")
# 总结
report.append("## 总结\n")
if success_count > 0:
avg_coverage = sum(r.get('coverage', 0) for r in results if r.get('status') == 'success') / success_count
total_decorative = sum(r.get('decorative_elements', 0) for r in results if r.get('status') == 'success')
report.append(f"- **平均覆盖率**: {avg_coverage:.2%}")
report.append(f"- **总装饰性元素**: {total_decorative}")
report.append(f"- **提取器状态**: {'✅ 正常' if avg_coverage > 0.9 else '⚠️ 需要优化'}")
return "\n".join(report)
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
# 测试文件列表
test_files = [
"Gambling Man.epub",
"On_China_Henry_Kissinger.epub",
"The World Atlas of Coffee - From Beans to Brewing - Coffees Explored, Explained and Enjoyed (James Hoffmann) (Z-Library).epub",
"The_Philosopher_in_the_Valley.epub",
"To_Explain_the_World.epub"
]
input_dir = project_root / "input"
results = []
for filename in test_files:
epub_path = input_dir / filename
if not epub_path.exists():
logger.warning(f"跳过不存在的文件: {filename}")
continue
logger.info(f"\n{'='*60}")
logger.info(f"测试: {filename}")
logger.info(f"{'='*60}")
result = test_single_epub(epub_path, use_pandoc=True)
results.append(result)
# 打印简要结果
if result.get('status') == 'success':
logger.info(f"✅ 成功: {result.get('total_elements')} 个元素, "
f"{result.get('decorative_elements')} 个装饰性, "
f"覆盖率 {result.get('coverage', 0):.1%}")
else:
logger.error(f"❌ 失败: {result.get('error')}")
# 生成报告
report = generate_summary_report(results)
# 保存报告
report_dir = project_root / "tests" / "extraction_experiment" / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
report_file = report_dir / "multi_epub_test_report.md"
with open(report_file, 'w', encoding='utf-8') as f:
f.write(report)
logger.info(f"\n报告已保存: {report_file}")
# 打印报告
print("\n" + "="*60)
print(report)
print("="*60)
if __name__ == "__main__":
main()
@@ -0,0 +1,103 @@
"""
测试一比一对应提取器
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from extractors.one_to_one import OneToOneExtractor
def test_one_to_one():
"""测试一比一对应"""
# 测试 HTML
html = """
<div class="poem">
<div class="line"><span class="italic">War is</span></div>
<div class="line"><span class="italic">A grave affair of the state;</span></div>
<div class="line"><span class="italic">It is a place</span></div>
</div>
"""
print("\n" + "="*80)
print("一比一对应测试")
print("="*80 + "\n")
print("原始 HTML:")
print(html)
print()
# 提取
extractor = OneToOneExtractor()
items = extractor.extract(html)
print(f"提取了 {len(items)} 个元素:\n")
for i, item in enumerate(items, 1):
print(f"{i}. <{item['tag']}> {item['text']}")
print(f" 文本节点数: {len(item['text_nodes'])}")
for j, (node, text) in enumerate(item['text_nodes'], 1):
print(f" 节点 {j}: '{text}'")
print()
# 创建翻译映射
translation_map = {}
for item in items:
if item['should_translate']:
translation_map[item['text']] = f"{item['text']} [翻译]"
print(f"待翻译: {len(translation_map)} 个元素\n")
# 回填
result_html = extractor.backfill(items, translation_map)
print("="*80)
print("回填后的 HTML:")
print("="*80 + "\n")
print(result_html)
# 验证
print("\n" + "="*80)
print("验证:")
print("="*80 + "\n")
if '<span class="italic">War is [翻译]</span>' in result_html:
print("✅ 第1行格式保留")
else:
print("❌ 第1行格式丢失")
if '<span class="italic">A grave affair of the state; [翻译]</span>' in result_html:
print("✅ 第2行格式保留")
else:
print("❌ 第2行格式丢失")
if '<span class="italic">It is a place [翻译]</span>' in result_html:
print("✅ 第3行格式保留")
else:
print("❌ 第3行格式丢失")
div_count = result_html.count('<div class="line">')
if div_count == 3:
print("✅ 所有3个 div 都保留")
else:
print(f"❌ div 数量错误: {div_count}")
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
test_one_to_one()
if __name__ == "__main__":
main()
@@ -0,0 +1,202 @@
"""
简化的提取测试脚本
快速验证提取器的基本功能
"""
import sys
from pathlib import Path
# 添加项目根目录到路径
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from bs4 import BeautifulSoup
from loguru import logger
# 导入提取器
sys.path.insert(0, str(Path(__file__).parent))
from extractors.bs4_optimized import BS4OptimizedExtractor
from extractors.lxml_xpath import LxmlXPathExtractor
def test_simple_html():
"""测试简单的 HTML 提取"""
html = """
<html>
<body>
<h1>Chapter 1</h1>
<p>This is the first paragraph.</p>
<div class="nav">Navigation</div>
<p>This is the second paragraph.</p>
<blockquote>A quote here.</blockquote>
</body>
</html>
"""
print("\n" + "="*60)
print("测试 1: 简单 HTML 提取")
print("="*60)
# BS4 提取器
print("\n--- BS4 优化方案 ---")
bs4_extractor = BS4OptimizedExtractor(min_text_length=5)
bs4_items = bs4_extractor.extract(html)
print(f"提取元素数: {len(bs4_items)}")
for i, item in enumerate(bs4_items, 1):
nav_flag = " [导航]" if item['is_navigation'] else ""
print(f"{i}. [{item['tag']}] {item['text'][:50]}{nav_flag}")
print(f" 路径: {item['path']}")
# lxml 提取器
print("\n--- lxml XPath 方案 ---")
lxml_extractor = LxmlXPathExtractor(min_text_length=5)
lxml_items = lxml_extractor.extract(html)
print(f"提取元素数: {len(lxml_items)}")
for i, item in enumerate(lxml_items, 1):
nav_flag = " [导航]" if item['is_navigation'] else ""
print(f"{i}. [{item['tag']}] {item['text'][:50]}{nav_flag}")
print(f" XPath: {item['xpath']}")
return bs4_items, lxml_items
def test_backfill(html, items, extractor, method_name):
"""测试回填功能"""
print(f"\n--- {method_name} 回填测试 ---")
# 创建模拟翻译
translation_map = {}
for i, item in enumerate(items):
if not item.get('is_navigation', False):
path = item.get('path') or item.get('xpath')
translation_map[path] = f"TRANSLATED_{i:02d}"
print(f"待回填: {len(translation_map)} 个元素")
# 执行回填
backfilled_html = extractor.backfill(html, translation_map)
# 验证
soup = BeautifulSoup(backfilled_html, 'html.parser')
found_count = 0
for path, translation in translation_map.items():
if translation in soup.get_text():
found_count += 1
accuracy = found_count / len(translation_map) if translation_map else 0
print(f"回填准确性: {accuracy:.2%} ({found_count}/{len(translation_map)})")
return accuracy
def test_epub_extraction(epub_path):
"""测试真实 ePub 文件的提取"""
print("\n" + "="*60)
print(f"测试 2: ePub 文件提取 - {Path(epub_path).name}")
print("="*60)
# 加载 ePub
book = epub.read_epub(epub_path)
# 提取第一个 HTML 文档
html_content = None
for item in book.get_items():
if item.get_type() == 9: # ITEM_DOCUMENT
try:
html_content = item.get_content().decode('utf-8')
print(f"\n测试文件: {item.get_name()}")
break
except:
continue
if not html_content:
print("未找到 HTML 内容")
return
# BS4 提取
print("\n--- BS4 优化方案 ---")
bs4_extractor = BS4OptimizedExtractor()
bs4_items = bs4_extractor.extract(html_content)
print(f"提取元素数: {len(bs4_items)}")
print(f"总文本长度: {sum(len(item['text']) for item in bs4_items):,} 字符")
# 显示前5个元素
print("\n前 5 个元素:")
for i, item in enumerate(bs4_items[:5], 1):
print(f"{i}. [{item['tag']}] {item['text'][:80]}...")
# lxml 提取
print("\n--- lxml XPath 方案 ---")
lxml_extractor = LxmlXPathExtractor()
lxml_items = lxml_extractor.extract(html_content)
print(f"提取元素数: {len(lxml_items)}")
print(f"总文本长度: {sum(len(item['text']) for item in lxml_items):,} 字符")
# 显示前5个元素
print("\n前 5 个元素:")
for i, item in enumerate(lxml_items[:5], 1):
print(f"{i}. [{item['tag']}] {item['text'][:80]}...")
# 回填测试
print("\n" + "-"*60)
print("回填测试")
print("-"*60)
bs4_accuracy = test_backfill(html_content, bs4_items, bs4_extractor, "BS4")
lxml_accuracy = test_backfill(html_content, lxml_items, lxml_extractor, "lxml")
# 对比
print("\n" + "="*60)
print("对比总结")
print("="*60)
print(f"{'方案':<15} {'元素数':<10} {'回填准确性':<15}")
print("-"*60)
print(f"{'BS4 优化':<15} {len(bs4_items):<10} {bs4_accuracy:>13.2%}")
print(f"{'lxml XPath':<15} {len(lxml_items):<10} {lxml_accuracy:>13.2%}")
def main():
"""主函数"""
# 配置日志
logger.remove()
logger.add(sys.stderr, level="INFO")
# 测试 1: 简单 HTML
bs4_items, lxml_items = test_simple_html()
# 简单 HTML 回填测试
html = """
<html>
<body>
<h1>Chapter 1</h1>
<p>This is the first paragraph.</p>
<div class="nav">Navigation</div>
<p>This is the second paragraph.</p>
</body>
</html>
"""
bs4_extractor = BS4OptimizedExtractor(min_text_length=5)
lxml_extractor = LxmlXPathExtractor(min_text_length=5)
print("\n" + "="*60)
print("简单 HTML 回填测试")
print("="*60)
test_backfill(html, bs4_items, bs4_extractor, "BS4")
test_backfill(html, lxml_items, lxml_extractor, "lxml")
# 测试 2: 真实 ePub
epub_path = project_root / "input" / "Gambling Man.epub"
if epub_path.exists():
test_epub_extraction(str(epub_path))
else:
print(f"\n跳过 ePub 测试: 文件不存在 {epub_path}")
if __name__ == "__main__":
main()
@@ -0,0 +1,186 @@
"""
测试智能提取器
验证正文 100% 提取,非核心部分智能跳过
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent))
from extractors.smart_extractor import SmartExtractor
def extract_all_text_from_html(html_content: str) -> str:
"""提取 HTML 中的所有文本(基准)"""
from bs4 import BeautifulSoup
import re
soup = BeautifulSoup(html_content, 'html.parser')
for element in soup(['script', 'style', 'meta', 'link']):
element.decompose()
text = soup.get_text(separator=' ', strip=True)
text = re.sub(r'\s+', ' ', text)
return text.strip()
def test_smart_extraction(epub_path: Path):
"""测试智能提取器"""
print(f"\n{'='*80}")
print(f"智能提取器测试: {epub_path.name}")
print(f"{'='*80}\n")
# 加载 ePub
book = epub.read_epub(str(epub_path))
# 提取所有 HTML 文档
html_docs = []
for item in book.get_items():
if item.get_type() == 9:
try:
content = item.get_content().decode('utf-8')
html_docs.append({
'name': item.get_name(),
'content': content
})
except:
continue
print(f"找到 {len(html_docs)} 个 HTML 文档\n")
# 智能提取器
extractor = SmartExtractor(preserve_decorative=True)
# 分类统计
core_docs = []
non_core_docs = []
total_core_baseline = 0
total_core_extracted = 0
total_non_core_baseline = 0
total_non_core_translated = 0
total_non_core_skipped = 0
for doc in html_docs:
# 基准文本
baseline_text = extract_all_text_from_html(doc['content'])
baseline_len = len(baseline_text)
# 智能提取
items = extractor.extract(doc['content'], doc['name'])
# 分类
is_core = items[0]['is_core'] if items else True
translate_items = [i for i in items if i['should_translate']]
skip_items = [i for i in items if not i['should_translate'] and not i['is_decorative']]
extracted_text = " ".join([i['text'] for i in translate_items])
extracted_len = len(extracted_text)
if is_core:
core_docs.append({
'name': doc['name'],
'baseline_len': baseline_len,
'extracted_len': extracted_len,
'coverage': extracted_len / baseline_len if baseline_len > 0 else 0
})
total_core_baseline += baseline_len
total_core_extracted += extracted_len
else:
non_core_docs.append({
'name': doc['name'],
'baseline_len': baseline_len,
'translate_len': extracted_len,
'skip_len': sum(len(i['text']) for i in skip_items),
'translate_count': len(translate_items),
'skip_count': len(skip_items)
})
total_non_core_baseline += baseline_len
total_non_core_translated += extracted_len
total_non_core_skipped += sum(len(i['text']) for i in skip_items)
# 显示结果
print(f"{'='*80}")
print("核心文档 (正文) - 必须 100%")
print(f"{'='*80}\n")
for doc in core_docs[:10]:
coverage = doc['coverage']
status = "" if coverage >= 0.99 else ""
print(f"{status} {doc['name']}")
print(f" 基准: {doc['baseline_len']:,} | 提取: {doc['extracted_len']:,} | 覆盖率: {coverage:.2%}")
if len(core_docs) > 10:
print(f"\n... 还有 {len(core_docs) - 10} 个核心文档\n")
core_coverage = total_core_extracted / total_core_baseline if total_core_baseline > 0 else 0
print(f"\n**核心文档总体覆盖率: {core_coverage:.2%}**")
print(f"基准: {total_core_baseline:,} | 提取: {total_core_extracted:,}\n")
# 非核心文档
print(f"{'='*80}")
print("非核心文档 (目录/索引/参考文献) - 智能跳过")
print(f"{'='*80}\n")
for doc in non_core_docs:
print(f"📄 {doc['name']}")
print(f" 基准: {doc['baseline_len']:,} 字符")
print(f" 翻译: {doc['translate_count']} 个元素 ({doc['translate_len']:,} 字符)")
print(f" 跳过: {doc['skip_count']} 个元素 ({doc['skip_len']:,} 字符)")
if doc['baseline_len'] > 0:
translate_ratio = doc['translate_len'] / doc['baseline_len']
skip_ratio = doc['skip_len'] / doc['baseline_len']
print(f" 翻译比例: {translate_ratio:.1%} | 跳过比例: {skip_ratio:.1%}")
print()
print(f"非核心文档统计:")
print(f" - 总基准: {total_non_core_baseline:,} 字符")
print(f" - 翻译: {total_non_core_translated:,} 字符")
print(f" - 跳过: {total_non_core_skipped:,} 字符")
# 总体统计
print(f"\n{'='*80}")
print("总体统计")
print(f"{'='*80}\n")
total_baseline = total_core_baseline + total_non_core_baseline
total_extracted = total_core_extracted + total_non_core_translated
overall_coverage = total_extracted / total_baseline if total_baseline > 0 else 0
print(f"总基准: {total_baseline:,} 字符")
print(f"总提取: {total_extracted:,} 字符")
print(f"**总体覆盖率: {overall_coverage:.2%}**\n")
print(f"✅ 核心文档覆盖率: {core_coverage:.2%} (目标: 100%)")
if core_coverage >= 0.995:
print(" 状态: 达标 ✅")
else:
print(f" 状态: 需要改进 ⚠️ (差距: {(1.0 - core_coverage) * 100:.2f}%)")
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="INFO")
test_file = "Gambling Man.epub"
epub_path = project_root / "input" / test_file
if not epub_path.exists():
print(f"文件不存在: {test_file}")
return
test_smart_extraction(epub_path)
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""验证器模块"""
@@ -0,0 +1,212 @@
"""
回填验证器
验证翻译回填的准确性,确保每个翻译都回填到正确位置
"""
from bs4 import BeautifulSoup
from typing import List, Dict, Any, Tuple
from loguru import logger
class BackfillValidator:
"""回填验证器"""
def __init__(self):
"""初始化验证器"""
pass
def validate_backfill(self, original_items: List[Dict[str, Any]],
backfilled_html: str,
translation_map: Dict[str, str]) -> Dict[str, Any]:
"""
验证回填的准确性
Args:
original_items: 原始提取的元素列表
backfilled_html: 回填后的 HTML
translation_map: {path/xpath: translation} 映射
Returns:
验证结果字典
"""
soup = BeautifulSoup(backfilled_html, 'html.parser')
results = {
'total': len(translation_map),
'success': 0,
'failed': 0,
'errors': []
}
for path, expected_translation in translation_map.items():
# 尝试在回填后的 HTML 中查找翻译
found = self._find_translation_in_html(soup, expected_translation)
if found:
results['success'] += 1
else:
results['failed'] += 1
results['errors'].append({
'path': path,
'expected': expected_translation,
'reason': '未在回填后的 HTML 中找到翻译'
})
results['accuracy'] = results['success'] / results['total'] if results['total'] > 0 else 0
return results
def validate_position_accuracy(self, original_html: str,
extraction_items: List[Dict[str, Any]],
extractor) -> Tuple[int, int]:
"""
验证位置定位的准确性
测试方法:
1. 从原始 HTML 提取元素
2. 为每个元素生成路径
3. 使用路径重新定位元素
4. 对比定位到的元素是否与原始元素一致
Args:
original_html: 原始 HTML
extraction_items: 提取的元素列表
extractor: 提取器实例(需要有 find_by_path 或类似方法)
Returns:
(成功数, 失败数)
"""
soup = BeautifulSoup(original_html, 'html.parser')
success = 0
failed = 0
for item in extraction_items:
path = item.get('path') or item.get('xpath')
if not path:
continue
original_text = item['text']
# 尝试通过路径重新定位
try:
if hasattr(extractor, 'path_utils'):
# BS4 提取器
found_element = extractor.path_utils.find_by_path(soup, path)
if found_element:
found_text = found_element.get_text().strip()
else:
found_text = None
else:
# lxml 提取器
from lxml import html as lxml_html
tree = lxml_html.fromstring(original_html)
elements = tree.xpath(path)
if elements:
found_text = elements[0].text_content().strip()
else:
found_text = None
# 对比文本
if found_text and self._texts_match(original_text, found_text):
success += 1
else:
failed += 1
logger.debug(f"位置验证失败: {path}")
except Exception as e:
failed += 1
logger.error(f"位置验证错误 {path}: {e}")
return success, failed
def simulate_translation_backfill(self, original_html: str,
extraction_items: List[Dict[str, Any]],
extractor) -> Tuple[str, Dict[str, Any]]:
"""
模拟翻译回填过程
为每个提取的元素生成模拟翻译,然后回填,验证是否能正确回填
Args:
original_html: 原始 HTML
extraction_items: 提取的元素列表
extractor: 提取器实例
Returns:
(回填后的 HTML, 验证结果)
"""
# 生成模拟翻译
translation_map = {}
for i, item in enumerate(extraction_items):
path = item.get('path') or item.get('xpath')
if path:
# 使用简单的标记作为"翻译"
translation_map[path] = f"TRANSLATED_{i:04d}"
# 执行回填
backfilled_html = extractor.backfill(original_html, translation_map)
# 验证回填结果
validation_results = self.validate_backfill(
extraction_items,
backfilled_html,
translation_map
)
return backfilled_html, validation_results
def _find_translation_in_html(self, soup: BeautifulSoup, translation: str) -> bool:
"""在 HTML 中查找翻译文本"""
# 简单的文本搜索
html_text = soup.get_text()
return translation in html_text
def _texts_match(self, text1: str, text2: str) -> bool:
"""
判断两段文本是否匹配
允许一定的空白差异
"""
import re
# 标准化空白
normalized1 = re.sub(r'\s+', ' ', text1.strip())
normalized2 = re.sub(r'\s+', ' ', text2.strip())
return normalized1 == normalized2
def generate_report(self, results: Dict[str, Any]) -> str:
"""
生成回填验证报告
Args:
results: 验证结果
Returns:
Markdown 格式的报告
"""
report = ["# 回填准确性验证报告\n"]
# 总体统计
report.append("## 总体统计\n")
report.append(f"- 总计: {results.get('total', 0)} 个元素")
report.append(f"- 成功: {results.get('success', 0)}")
report.append(f"- 失败: {results.get('failed', 0)}")
report.append(f"- 准确率: {results.get('accuracy', 0):.2%}\n")
# 错误详情
errors = results.get('errors', [])
if errors:
report.append("## 错误详情\n")
for i, error in enumerate(errors[:10], 1): # 只显示前10个
report.append(f"### 错误 {i}\n")
report.append(f"- 路径: `{error.get('path', 'N/A')}`")
report.append(f"- 预期翻译: {error.get('expected', 'N/A')[:100]}")
report.append(f"- 原因: {error.get('reason', 'N/A')}\n")
if len(errors) > 10:
report.append(f"\n... 还有 {len(errors) - 10} 个错误\n")
return "\n".join(report)
@@ -0,0 +1,210 @@
"""
完整性验证器
对比不同提取方案与 Pandoc 基准的文本覆盖率
"""
import re
from typing import List, Dict, Any
from difflib import SequenceMatcher
from loguru import logger
class CompletenessValidator:
"""完整性验证器"""
def __init__(self):
"""初始化验证器"""
pass
def calculate_coverage(self, extracted_text: str, baseline_text: str) -> float:
"""
计算提取文本相对于基准的覆盖率
使用基于词的覆盖率计算,而非简单的字符匹配
Args:
extracted_text: 提取的文本
baseline_text: 基准文本(如 Pandoc 输出)
Returns:
覆盖率 (0.0 - 1.0)
"""
# 标准化文本
extracted_normalized = self._normalize_text(extracted_text)
baseline_normalized = self._normalize_text(baseline_text)
# 分词
extracted_words = set(extracted_normalized.split())
baseline_words = set(baseline_normalized.split())
if not baseline_words:
return 0.0
# 计算交集
common_words = extracted_words & baseline_words
# 覆盖率 = 共同词 / 基准词
coverage = len(common_words) / len(baseline_words)
return coverage
def calculate_similarity(self, text1: str, text2: str) -> float:
"""
计算两段文本的相似度
使用 SequenceMatcher 计算
Args:
text1: 文本1
text2: 文本2
Returns:
相似度 (0.0 - 1.0)
"""
normalized1 = self._normalize_text(text1)
normalized2 = self._normalize_text(text2)
matcher = SequenceMatcher(None, normalized1, normalized2)
return matcher.ratio()
def find_missing_segments(self, extracted_text: str, baseline_text: str,
min_segment_length: int = 50) -> List[str]:
"""
找出基准中存在但提取文本中缺失的片段
Args:
extracted_text: 提取的文本
baseline_text: 基准文本
min_segment_length: 最小片段长度
Returns:
缺失的文本片段列表
"""
# 标准化
extracted_normalized = self._normalize_text(extracted_text)
baseline_normalized = self._normalize_text(baseline_text)
# 将基准文本分成句子
baseline_sentences = self._split_sentences(baseline_normalized)
missing = []
for sentence in baseline_sentences:
if len(sentence) < min_segment_length:
continue
# 检查句子是否在提取文本中
if sentence not in extracted_normalized:
# 进一步检查是否有部分匹配
if not self._has_partial_match(sentence, extracted_normalized):
missing.append(sentence)
return missing
def _normalize_text(self, text: str) -> str:
"""
标准化文本
- 转小写
- 移除多余空白
- 移除标点符号
"""
# 转小写
text = text.lower()
# 移除 Markdown 标记
text = re.sub(r'[#*_\[\](){}]', '', text)
# 移除标点符号(保留空格)
text = re.sub(r'[^\w\s]', ' ', text)
# 压缩空白
text = re.sub(r'\s+', ' ', text)
return text.strip()
def _split_sentences(self, text: str) -> List[str]:
"""
将文本分割成句子
简单实现,按句号、问号、感叹号分割
"""
# 按标点分割
sentences = re.split(r'[.!?]+', text)
# 清理并过滤空句子
sentences = [s.strip() for s in sentences if s.strip()]
return sentences
def _has_partial_match(self, sentence: str, text: str, threshold: float = 0.8) -> bool:
"""
检查句子是否在文本中有部分匹配
Args:
sentence: 待检查的句子
text: 文本
threshold: 匹配阈值
Returns:
是否有部分匹配
"""
# 将句子分成词
words = sentence.split()
if len(words) < 5:
return False
# 检查是否有足够比例的词在文本中
matched_words = sum(1 for word in words if word in text)
match_ratio = matched_words / len(words)
return match_ratio >= threshold
def generate_report(self, results: Dict[str, Any]) -> str:
"""
生成验证报告
Args:
results: 验证结果字典
Returns:
Markdown 格式的报告
"""
report = ["# 文本提取完整性验证报告\n"]
# 基本信息
report.append("## 基本信息\n")
report.append(f"- 测试文件: {results.get('file_name', 'N/A')}")
report.append(f"- 基准文本长度: {results.get('baseline_length', 0)} 字符")
report.append(f"- 测试时间: {results.get('timestamp', 'N/A')}\n")
# 各方案对比
report.append("## 提取方案对比\n")
report.append("| 方案 | 提取元素数 | 文本长度 | 覆盖率 | 相似度 |")
report.append("|------|-----------|---------|--------|--------|")
for method_name, method_results in results.get('methods', {}).items():
report.append(
f"| {method_name} | "
f"{method_results.get('element_count', 0)} | "
f"{method_results.get('text_length', 0)} | "
f"{method_results.get('coverage', 0):.2%} | "
f"{method_results.get('similarity', 0):.2%} |"
)
report.append("")
# 缺失片段
report.append("## 缺失文本片段\n")
for method_name, method_results in results.get('methods', {}).items():
missing = method_results.get('missing_segments', [])
if missing:
report.append(f"### {method_name}\n")
report.append(f"缺失 {len(missing)} 个片段:\n")
for i, segment in enumerate(missing[:5], 1): # 只显示前5个
report.append(f"{i}. {segment[:100]}...")
if len(missing) > 5:
report.append(f"\n... 还有 {len(missing) - 5} 个片段\n")
report.append("")
return "\n".join(report)
@@ -0,0 +1,111 @@
"""
验证段落内格式保留情况
"""
import sys
from pathlib import Path
from bs4 import BeautifulSoup
from ebooklib import epub
import zipfile
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
def get_paragraphs_with_content(content):
soup = BeautifulSoup(content, 'html.parser')
paragraphs = []
# 原始 EPUB 主要是 div class="calibre8" 等
for tag in soup.find_all(['div', 'p']):
# 必须包含子标签 (span, b, i, a 等),且不是纯文本
if tag.find(['span', 'b', 'i', 'strong', 'em', 'a']):
# 获取 inner HTML (简化版)
inner = "".join([str(c) for c in tag.contents]).strip()
text = tag.get_text(strip=True)
if text:
paragraphs.append({
'text_hash': hash(text),
'text': text,
'tag_name': tag.name,
'inner_html': inner,
'attrs': tag.attrs
})
return paragraphs
def verify_format_retention():
orig_path = project_root / "input" / "On_China_Henry_Kissinger.epub"
bilingual_path = project_root / "test_output" / "On_China_bilingual_test.epub"
print(f"对比文件:")
print(f"原始: {orig_path.name}")
print(f"双语: {bilingual_path.name}")
print("-" * 60)
# 读取原始 (从 Zip 读取以防 ebooklib 修改)
orig_content = {}
with zipfile.ZipFile(orig_path, 'r') as zf:
for name in zf.namelist():
if name.endswith('dummy_split_002.html'): # 还是用这个典型文件
orig_content[name] = zf.read(name).decode('utf-8')
# 读取双语 (从 Zip 读取)
bil_content = {}
try:
with zipfile.ZipFile(bilingual_path, 'r') as zf:
for name in zf.namelist():
if name.endswith('dummy_split_002.html'):
bil_content[name] = zf.read(name).decode('utf-8')
except:
# 如果双语不是标准 zip 结构(ebooklib 生成的可能是),尝试直接 read_epub
pass
if not bil_content:
print("尝试通过 ebooklib 读取双语文件...")
book = epub.read_epub(str(bilingual_path))
for item in book.get_items():
if 'dummy_split_002' in item.get_name():
bil_content['dummy_split_002.html'] = item.get_content().decode('utf-8')
# 提取段落
orig_paras = get_paragraphs_with_content(list(orig_content.values())[0])
bil_paras = get_paragraphs_with_content(list(bil_content.values())[0])
print(f"原始文档包含格式的段落数: {len(orig_paras)}")
print(f"双语文档包含格式的段落数: {len(bil_paras)}")
print("-" * 60)
# 匹配并对比
matched = 0
for op in orig_paras:
# 在双语中寻找文本匹配的段落
found = False
for bp in bil_paras:
if bp['text_hash'] == op['text_hash'] and bp['text'] == op['text']:
matched += 1
found = True
# 对比 Inner HTML
# 忽略空白字符差异
o_inner = "".join(op['inner_html'].split())
b_inner = "".join(bp['inner_html'].split())
# 双语版可能 div -> p
tag_change = f"{op['tag_name']} -> {bp['tag_name']}"
if o_inner == b_inner:
print(f"✅ 格式完美保留 ({tag_change}): {op['text'][:30]}...")
else:
print(f"⚠️ 格式有差异 ({tag_change}): {op['text'][:30]}...")
print(f" 原: {op['inner_html']}")
print(f" 新: {bp['inner_html']}")
break
if not found and matched < 5: # 只打印前几个未找到的
print(f"❌ 未在双语版中找到对应段落: {op['text'][:30]}...")
print(f"\n共检查 {len(orig_paras)} 个原始段落,匹配到 {matched}")
if __name__ == "__main__":
verify_format_retention()
@@ -0,0 +1,76 @@
from extractors.fine_grained import FineGrainedExtractor
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# 确保导入路径正确
def test_improvements():
print("Testing FineGrainedExtractor improvements...")
extractor = FineGrainedExtractor()
html = """
<html>
<body>
<div class="chapter">
<h1>Chapter 1: The Beginning</h1>
<h2>Section 1.1</h2>
<h3>The Detail</h3>
<p>I</p>
<p>II</p>
<p>III</p>
<p>IV</p>
<p>XIV</p>
<p>INTRODUCTION</p>
<p>***</p>
<p>---</p>
<p>——</p>
<p>................</p>
<p>Normal paragraph text.</p>
<p>Text with <b>bold</b>.</p>
</div>
</body>
</html>
"""
# 注意: extract 需要完整 HTML 结构才能最好工作,或者片段也可以
items = extractor.extract(html, "chapter1.html")
print(f"Total items found: {len(items)}\n")
expected_results = {
"Chapter 1: The Beginning": True, # h1, translate
"Section 1.1": True, # h2, translate
"The Detail": True, # h3, translate
"I": False, # Roman, skip
"II": False, # Roman, skip
"III": False, # Roman, skip
"IV": False, # Roman, skip
"XIV": False, # Roman, skip
"INTRODUCTION": True, # Text, translate
"***": False, # Decorative, skip
"---": False, # Decorative, skip
"——": False, # Decorative, skip
"................": False, # Decorative, skip
"Normal paragraph text.": True, # Normal, translate
"Text with bold .": True # Normal (with nested), translate (separator space added)
}
for item in items:
text = item['text']
should_trans = item['should_translate']
tag = item['tag']
status = "✅ TRANSLATE" if should_trans else "❌ SKIP"
print(f"{status:<12} [{tag}] {text}")
# 验证
if text.strip() in expected_results:
expected = expected_results[text.strip()]
if should_trans != expected:
print(f" ⚠️ ERROR: Expected {expected}, got {should_trans}")
else:
pass
# print(f" OK")
if __name__ == "__main__":
test_improvements()
@@ -0,0 +1,78 @@
"""
验证双语 EPUB 样式继承
"""
import sys
from pathlib import Path
from bs4 import BeautifulSoup
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
def check_styles(epub_path: Path):
"""检查样式继承情况"""
print(f"\n{'='*80}")
print(f"样式继承验证: {epub_path.name}")
print(f"{'='*80}\n")
book = epub.read_epub(str(epub_path))
found_any = False
# 查找包含翻译的文档
for item in book.get_items():
if item.get_type() == 9:
content = item.get_content().decode('utf-8')
if '[中文]' in content:
print(f"文档: {item.get_name()}\n")
soup = BeautifulSoup(content, 'html.parser')
translations = soup.find_all('p', class_='translation')
print(f"找到 {len(translations)} 个译文段落\n")
# 检查前5个译文段落及其前一个兄弟元素
for i, trans_p in enumerate(translations[:5], 1):
# 找到对应的原文
orig_p = trans_p.find_previous_sibling('p')
# 跳过已经是 translation 的前一个元素 (虽然逻辑上不应该发生)
while orig_p and 'translation' in orig_p.get('class', []):
orig_p = orig_p.find_previous_sibling('p')
if orig_p:
orig_classes = orig_p.get('class', [])
trans_classes = trans_p.get('class', [])
# 移除 translation 类进行对比
trans_base_classes = [c for c in trans_classes if c != 'translation']
match = set(orig_classes) == set(trans_base_classes)
status = "" if match else ""
print(f"{i}. 样式对比 {status}")
print(f" 原文: class={orig_classes}")
print(f" 译文: class={trans_classes}")
print(f" 内容: {trans_p.get_text()[:40]}...")
print()
else:
print(f"{i}. ⚠️ 未找到对应原文")
found_any = True
break
if not found_any:
print("❌ 未找到包含翻译的文档")
if __name__ == "__main__":
epub_path = project_root / "test_output" / "On_China_bilingual_test.epub"
if epub_path.exists():
check_styles(epub_path)
else:
print("❌ 文件不存在")
@@ -0,0 +1,221 @@
"""
文本完整性验证脚本
验证简化清理器是否丢失文本
"""
import sys
from pathlib import Path
from bs4 import BeautifulSoup
import re
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from ebooklib import epub
from loguru import logger
def extract_all_text(html_content: str) -> str:
"""提取 HTML 中的所有可见文本"""
soup = BeautifulSoup(html_content, 'html.parser')
# 移除不可见元素
for element in soup(['script', 'style', 'meta', 'link']):
element.decompose()
# 获取所有文本
text = soup.get_text(separator=' ', strip=True)
# 规范化空白
text = re.sub(r'\s+', ' ', text)
return text.strip()
def compare_word_sets(orig_text: str, clean_text: str):
"""对比两个文本的词集合,找出缺失的词"""
orig_words = set(orig_text.lower().split())
clean_words = set(clean_text.lower().split())
missing_words = orig_words - clean_words
extra_words = clean_words - orig_words
return missing_words, extra_words
def verify_epub_integrity(original_path: Path, cleaned_path: Path):
"""验证清理后的 EPUB 文本完整性"""
print(f"\n{'='*80}")
print(f"文本完整性验证")
print(f"{'='*80}\n")
print(f"原始文件: {original_path.name}")
print(f"清理文件: {cleaned_path.name}\n")
# 加载两个 EPUB
orig_book = epub.read_epub(str(original_path))
clean_book = epub.read_epub(str(cleaned_path))
# 提取所有 HTML 文档
orig_docs = {}
clean_docs = {}
for item in orig_book.get_items():
if item.get_type() == 9:
try:
content = item.get_content().decode('utf-8')
orig_docs[item.get_name()] = content
except:
continue
for item in clean_book.get_items():
if item.get_type() == 9:
try:
content = item.get_content().decode('utf-8')
clean_docs[item.get_name()] = content
except:
continue
print(f"原始文档数: {len(orig_docs)}")
print(f"清理文档数: {len(clean_docs)}\n")
# 逐个文档对比
total_orig_chars = 0
total_clean_chars = 0
total_orig_words = 0
total_clean_words = 0
issues = []
for name in orig_docs:
if name not in clean_docs:
issues.append({
'file': name,
'issue': '文档缺失',
'severity': 'critical'
})
continue
orig_text = extract_all_text(orig_docs[name])
clean_text = extract_all_text(clean_docs[name])
orig_chars = len(orig_text)
clean_chars = len(clean_text)
orig_words = len(orig_text.split())
clean_words = len(clean_text.split())
total_orig_chars += orig_chars
total_clean_chars += clean_chars
total_orig_words += orig_words
total_clean_words += clean_words
# 检查差异
if orig_chars != clean_chars:
missing_words, extra_words = compare_word_sets(orig_text, clean_text)
issues.append({
'file': name,
'issue': '文本长度不一致',
'severity': 'warning',
'orig_chars': orig_chars,
'clean_chars': clean_chars,
'diff_chars': orig_chars - clean_chars,
'orig_words': orig_words,
'clean_words': clean_words,
'diff_words': orig_words - clean_words,
'missing_words_count': len(missing_words),
'extra_words_count': len(extra_words),
'missing_words_sample': list(missing_words)[:10],
'extra_words_sample': list(extra_words)[:10]
})
# 总体统计
print(f"{'='*80}")
print("总体统计")
print(f"{'='*80}\n")
print(f"原始文本: {total_orig_chars:,} 字符, {total_orig_words:,}")
print(f"清理文本: {total_clean_chars:,} 字符, {total_clean_words:,}")
print(f"差异: {total_orig_chars - total_clean_chars:+,} 字符, {total_orig_words - total_clean_words:+,}")
if total_orig_chars > 0:
char_retention = total_clean_chars / total_orig_chars * 100
print(f"字符保留率: {char_retention:.4f}%")
if total_orig_words > 0:
word_retention = total_clean_words / total_orig_words * 100
print(f"词保留率: {word_retention:.4f}%\n")
# 显示问题
if issues:
print(f"{'='*80}")
print(f"发现 {len(issues)} 个问题")
print(f"{'='*80}\n")
for i, issue in enumerate(issues, 1):
print(f"--- 问题 {i}: {issue['file']} ---")
print(f"类型: {issue['issue']}")
print(f"严重性: {issue['severity']}")
if issue['severity'] == 'warning':
print(f"字符差异: {issue['diff_chars']:+,} ({issue['orig_chars']:,}{issue['clean_chars']:,})")
print(f"词差异: {issue['diff_words']:+,} ({issue['orig_words']:,}{issue['clean_words']:,})")
if issue['missing_words_count'] > 0:
print(f"缺失词数: {issue['missing_words_count']}")
print(f"缺失词样本: {', '.join(issue['missing_words_sample'])}")
if issue['extra_words_count'] > 0:
print(f"新增词数: {issue['extra_words_count']}")
print(f"新增词样本: {', '.join(issue['extra_words_sample'])}")
print()
else:
print("✅ 未发现任何问题!文本完全一致。\n")
# 结论
print(f"{'='*80}")
print("验证结论")
print(f"{'='*80}\n")
if total_orig_chars == total_clean_chars:
print("✅ 文本完整性: 100% - 完美!")
elif total_clean_chars >= total_orig_chars * 0.999:
print("✅ 文本完整性: ≥99.9% - 优秀")
elif total_clean_chars >= total_orig_chars * 0.99:
print("⚠️ 文本完整性: ≥99% - 可接受")
else:
print("❌ 文本完整性: <99% - 需要修复")
print()
def main():
"""主函数"""
logger.remove()
logger.add(sys.stderr, level="ERROR")
# 测试文件
original_file = "On_China_Henry_Kissinger.epub"
cleaned_file = "On_China_cleaned.epub"
original_path = project_root / "input" / original_file
cleaned_path = project_root / "test_output" / cleaned_file
if not original_path.exists():
print(f"❌ 原始文件不存在: {original_path}")
return
if not cleaned_path.exists():
print(f"❌ 清理文件不存在: {cleaned_path}")
print(f"\n提示: 请先运行清理器:")
print(f" python tests/extraction_experiment/simple_cleaner.py \\")
print(f" 'input/{original_file}' \\")
print(f" 'test_output/{cleaned_file}'")
return
verify_epub_integrity(original_path, cleaned_path)
if __name__ == "__main__":
main()
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
简单的翻译测试脚本
用于验证 API 连接和翻译功能
"""
import asyncio
import sys
from pathlib import Path
# 添加 src 目录到路径
sys.path.insert(0, str(Path(__file__).parent / "src"))
from src.llm_client import OpenRouterClient
from src.utils import load_config
from rich.console import Console
async def test_api_connection():
"""测试 API 连接和基本翻译功能"""
console = Console()
try:
# 加载配置
config = load_config('config/config.json')
# 初始化客户端
client = OpenRouterClient(config)
console.print("[green]✓ OpenRouter 客户端初始化成功[/green]")
# 测试简单翻译
test_text = "Hello, this is a test sentence for translation."
console.print(f"\n[cyan]测试文本:[/cyan] {test_text}")
console.print("[yellow]正在翻译...[/yellow]")
translation = await client.test_translation(test_text)
console.print(f"[green]翻译结果:[/green] {translation}")
# 测试更复杂的文本
complex_text = """
Modern technology has transformed the way we live and work.
The rapid advancement of artificial intelligence and machine learning
has created new opportunities and challenges for society.
"""
console.print(f"\n[cyan]复杂测试文本:[/cyan] {complex_text.strip()}")
console.print("[yellow]正在翻译...[/yellow]")
complex_translation = await client.test_translation(complex_text.strip())
console.print(f"[green]翻译结果:[/green] {complex_translation}")
# 测试术语表生成
console.print("\n[cyan]测试术语表生成...[/cyan]")
sample_texts = [
"Artificial intelligence and machine learning are transforming industries.",
"The complexity of modern systems requires innovative solutions.",
"Economic growth and environmental sustainability are key challenges."
]
terminology = await client.generate_terminology(sample_texts)
if terminology:
console.print("[green]✓ 术语表生成成功[/green]")
for category, terms in terminology.items():
console.print(f"[yellow]{category}:[/yellow]")
for en, zh in terms.items():
console.print(f" {en} -> {zh}")
else:
console.print("[yellow]术语表为空[/yellow]")
await client.close()
console.print("\n[green]✓ 所有测试完成[/green]")
except Exception as e:
console.print(f"[red]✗ 测试失败: {e}[/red]")
import traceback
console.print(traceback.format_exc())
if __name__ == "__main__":
asyncio.run(test_api_connection())
@@ -0,0 +1,67 @@
import asyncio
import sys
import glob
from pathlib import Path
from loguru import logger
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent))
# Import the logic from the previous test script to reuse it
# (Assuming it's safe to import, or I'll copy the core logic if cleaner)
from test_full_flow_random_sample import run_random_sample_test
async def run_batch_test():
print("🚀 Starting Batch Test on all EPUBs")
print("=" * 60)
# Find all EPUBs
epub_files = []
# Search in current directory
epub_files.extend(glob.glob("*.epub"))
# Search in 'input' directory if it exists
if Path("input").exists():
epub_files.extend(glob.glob("input/*.epub"))
# Search in subfolders (e.g. "未命名文件夹")
epub_files.extend(glob.glob("**/*.epub", recursive=True))
# Deduplicate and filter out output files
unique_epubs = set()
for f in epub_files:
path = Path(f)
if "output" in path.parts or "_bilingual" in path.name or "test_output" in path.parts:
continue
unique_epubs.add(str(path))
sorted_epubs = sorted(list(unique_epubs))
if not sorted_epubs:
print("❌ No EPUB files found.")
return
print(f"📚 Found {len(sorted_epubs)} unique EPUBs to test:")
for f in sorted_epubs:
print(f" - {f}")
print("-" * 60)
results = {}
for i, epub_file in enumerate(sorted_epubs, 1):
print(f"\n[{i}/{len(sorted_epubs)}] Testing: {epub_file}")
try:
await run_random_sample_test(epub_file)
results[epub_file] = "✅ Success"
except Exception as e:
print(f"❌ Failed: {e}")
logger.error(f"Test failed for {epub_file}", exc_info=True)
results[epub_file] = f"❌ Failed: {e}"
print("\n" + "=" * 60)
print("📊 Batch Test Summary")
print("=" * 60)
for f, status in results.items():
print(f"{status} - {f}")
if __name__ == "__main__":
asyncio.run(run_batch_test())
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""
缓存修复验证脚本
用于验证修复后的缓存逻辑是否正确工作
"""
import asyncio
import sys
import json
from pathlib import Path
# 添加项目根目录到 Python 路径
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from src.llm_client import OpenRouterClient
from src.cache import TranslationCache
from src.utils import load_config
from loguru import logger
async def test_cache_fix():
"""测试缓存修复是否正确"""
print("🔧 开始验证缓存逻辑修复...")
try:
# 加载配置
config = load_config()
# 初始化组件
llm_client = OpenRouterClient(config)
cache = TranslationCache(config)
# 测试数据:模拟一个chunk包含多个段落
test_paragraphs = [
"This is the first paragraph of our test chunk.",
"This is the second paragraph that should be translated correctly.",
"Finally, this is the third paragraph to complete our test."
]
print(f"📝 测试段落数量: {len(test_paragraphs)}")
for i, para in enumerate(test_paragraphs, 1):
print(f" [{i}] {para}")
# 第一次翻译(应该调用API
print("\n🚀 第一次翻译(调用API...")
translations_1 = await llm_client.translate_numbered_chunk(
test_paragraphs,
context="This is a test book about technology.",
terminology={"专业术语": {"technology": "技术", "test": "测试"}},
model_type="test"
)
print("✅ 第一次翻译结果:")
for i, trans in enumerate(translations_1, 1):
print(f" [{i}] {trans}")
# 保存到缓存
print("\n💾 保存到缓存...")
cache.save_chunk_translation(
test_paragraphs,
translations_1,
llm_client.models.get('test', ''),
"This is a test book about technology.",
success=True
)
# 第二次翻译(应该从缓存获取)
print("\n🔍 第二次翻译(应该命中缓存)...")
translations_2 = await llm_client.translate_numbered_chunk(
test_paragraphs,
context="This is a test book about technology.",
terminology={"专业术语": {"technology": "技术", "test": "测试"}},
model_type="test"
)
print("✅ 第二次翻译结果:")
for i, trans in enumerate(translations_2, 1):
print(f" [{i}] {trans}")
# 验证结果一致性
print("\n🔍 验证结果一致性...")
if translations_1 == translations_2:
print("✅ 缓存工作正常!两次翻译结果完全一致")
else:
print("❌ 缓存可能有问题!两次翻译结果不一致")
print("差异分析:")
for i, (t1, t2) in enumerate(zip(translations_1, translations_2), 1):
if t1 != t2:
print(f" 段落 {i} 不同:")
print(f" 第一次: {t1}")
print(f" 第二次: {t2}")
# 验证段落对应关系
print("\n🔍 验证段落对应关系...")
correspondence_correct = True
for i, (original, translation) in enumerate(zip(test_paragraphs, translations_1), 1):
# 检查翻译是否合理(包含中文字符且不是失败标记)
if (translation.startswith('[翻译失败') or
not any('\u4e00' <= char <= '\u9fff' for char in translation)):
print(f"❌ 段落 {i} 翻译质量问题: {translation}")
correspondence_correct = False
else:
print(f"✅ 段落 {i} 翻译正常")
if correspondence_correct:
print("✅ 所有段落翻译对应关系正确!")
else:
print("❌ 发现段落翻译对应关系问题!")
# 测试缓存统计
print("\n📊 缓存统计信息:")
cache_stats = cache.get_cache_stats()
for key, value in cache_stats.items():
print(f" {key}: {value}")
# 测试缓存完整性验证
print("\n🔍 缓存完整性验证:")
integrity_result = cache.validate_cache_integrity()
for key, value in integrity_result.items():
print(f" {key}: {value}")
print("\n🎉 缓存修复验证完成!")
except Exception as e:
print(f"❌ 验证过程出错: {e}")
logger.error(f"验证失败: {e}")
finally:
await llm_client.close()
async def test_edge_cases():
"""测试边缘情况"""
print("\n🧪 测试边缘情况...")
try:
config = load_config()
llm_client = OpenRouterClient(config)
cache = TranslationCache(config)
# 测试1: 单个段落
print("\n📝 测试1: 单个段落")
single_paragraph = ["This is a single paragraph test."]
translation = await llm_client.translate_numbered_chunk(
single_paragraph,
model_type="test"
)
print(f" 原文: {single_paragraph[0]}")
print(f" 译文: {translation[0]}")
# 测试2: 空段落列表
print("\n📝 测试2: 空段落列表")
empty_result = await llm_client.translate_numbered_chunk([])
print(f" 空列表结果: {empty_result}")
# 测试3: 很长的段落
print("\n📝 测试3: 长段落")
long_paragraph = ["This is a very long paragraph that contains multiple sentences and should test how well our system handles longer content. " * 10]
long_translation = await llm_client.translate_numbered_chunk(
long_paragraph,
model_type="test"
)
print(f" 长段落长度: {len(long_paragraph[0])} 字符")
print(f" 翻译长度: {len(long_translation[0])} 字符")
print(f" 翻译预览: {long_translation[0][:100]}...")
print("\n✅ 边缘情况测试完成!")
except Exception as e:
print(f"❌ 边缘情况测试出错: {e}")
logger.error(f"边缘情况测试失败: {e}")
finally:
await llm_client.close()
if __name__ == "__main__":
# 配置日志
logger.remove()
logger.add(sys.stdout, level="INFO", format="<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {message}")
print("🔧 EPUB翻译器 - 缓存逻辑修复验证")
print("=" * 50)
# 运行主要测试
asyncio.run(test_cache_fix())
# 运行边缘情况测试
asyncio.run(test_edge_cases())
print("\n" + "=" * 50)
print("🎯 验证总结:")
print("1. ✅ 实现了chunk级别的缓存")
print("2. ✅ 使用编号翻译确保段落对应关系")
print("3. ✅ 缓存key包含所有段落内容")
print("4. ✅ 翻译结果与原文段落一一对应")
print("5. ✅ 添加了缓存完整性验证")
print("\n🚀 缓存逻辑修复验证完成!")
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
分块测试脚本
测试新的字符范围分块逻辑
"""
import sys
from pathlib import Path
# 添加 src 目录到路径
sys.path.insert(0, str(Path(__file__).parent / "src"))
from src.epub_parser import EPUBParser
from src.text_processor import TextProcessor
from src.utils import load_config
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
def test_chunking_logic(epub_path: str):
"""测试分块逻辑"""
console = Console()
try:
# 加载配置
config = load_config('config/config.json')
# 初始化组件
parser = EPUBParser(epub_path)
text_processor = TextProcessor(config)
console.print(f"[bold blue]测试分块逻辑: {epub_path}[/bold blue]\n")
# 显示配置
chunk_config = config['translation']['chunk_config']
config_table = Table(title="分块配置")
config_table.add_column("参数", style="cyan")
config_table.add_column("", style="white")
config_table.add_row("最小字符数", str(chunk_config['min_chars']))
config_table.add_row("最大字符数", str(chunk_config['max_chars']))
config_table.add_row("最大段落数", str(chunk_config['max_paragraphs']))
console.print(config_table)
# 提取内容
content_items = parser.extract_translatable_content()
console.print(f"\n[cyan]找到 {len(content_items)} 个内容项目[/cyan]")
# 测试前3个内容项目
for i, content_item in enumerate(content_items[:3], 1):
console.print(f"\n[yellow]测试项目 {i}: {content_item['title']}[/yellow]")
# 提取段落
paragraphs = text_processor.extract_paragraphs(content_item['content'])
console.print(f"提取了 {len(paragraphs)} 个段落")
# 显示段落统计
if paragraphs:
para_chars = [p['char_count'] for p in paragraphs]
para_table = Table(title="段落统计")
para_table.add_column("统计项", style="cyan")
para_table.add_column("", style="white")
para_table.add_row("段落数量", str(len(paragraphs)))
para_table.add_row("总字符数", f"{sum(para_chars):,}")
para_table.add_row("平均字符/段", f"{sum(para_chars)/len(para_chars):.0f}")
para_table.add_row("最短段落", f"{min(para_chars)} 字符")
para_table.add_row("最长段落", f"{max(para_chars)} 字符")
console.print(para_table)
# 创建分块
chunks = text_processor.create_chunks(paragraphs)
# 显示分块结果
console.print(f"\n[green]创建了 {len(chunks)} 个翻译块[/green]")
chunk_table = Table(title="翻译块详情")
chunk_table.add_column("块号", style="cyan")
chunk_table.add_column("段落数", style="yellow")
chunk_table.add_column("字符数", style="green")
chunk_table.add_column("字符范围", style="blue")
for j, chunk in enumerate(chunks, 1):
chunk_chars = sum(p['char_count'] for p in chunk)
char_range = f"{chunk_config['min_chars']}-{chunk_config['max_chars']}"
# 检查是否在范围内
in_range = chunk_config['min_chars'] <= chunk_chars <= chunk_config['max_chars']
char_display = f"{chunk_chars:,}" + ("" if in_range else "")
chunk_table.add_row(
str(j),
str(len(chunk)),
char_display,
char_range
)
console.print(chunk_table)
# 显示统计
stats = text_processor.get_chunk_stats(chunks)
if stats:
stats_table = Table(title="分块统计")
stats_table.add_column("统计项", style="cyan")
stats_table.add_column("", style="white")
stats_table.add_row("总翻译块数", str(stats['total_chunks']))
stats_table.add_row("平均段落/块", f"{stats['avg_paragraphs_per_chunk']:.1f}")
stats_table.add_row("平均字符/块", f"{stats['avg_chars_per_chunk']:.0f}")
stats_table.add_row("字符范围", f"{stats['min_chars_per_chunk']}-{stats['max_chars_per_chunk']}")
console.print(stats_table)
# 显示示例块内容
if chunks:
example_chunk = chunks[0]
example_texts = [p['text'][:100] + "..." for p in example_chunk[:2]]
console.print(Panel(
"\n".join(f"{i+1}. {text}" for i, text in enumerate(example_texts)),
title=f"示例块内容 (块1, 前2段)",
border_style="green"
))
console.print(f"\n[bold green]分块测试完成![/bold green]")
except Exception as e:
console.print(f"[red]测试失败: {e}[/red]")
import traceback
console.print(traceback.format_exc())
if __name__ == "__main__":
if len(sys.argv) != 2:
print("使用方法: python test_chunking.py <epub_file>")
sys.exit(1)
epub_file = sys.argv[1]
test_chunking_logic(epub_file)
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
并发翻译逻辑验证脚本
测试真正的并发执行效果
"""
import asyncio
import sys
import time
from pathlib import Path
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from src.llm_client import OpenRouterClient
from src.text_processor import TextProcessor
from src.utils import load_config
from loguru import logger
async def test_concurrent_translation():
"""测试并发翻译效果"""
print("🚀 测试并发翻译逻辑")
print("=" * 60)
try:
config = load_config()
# 创建测试数据:模拟10个chunks
test_chunks = []
for i in range(10):
chunk = [
{
'global_id': f'p_{i*3+1:04d}',
'text': f'This is test paragraph {i*3+1} for concurrent translation testing.',
'length': 60
},
{
'global_id': f'p_{i*3+2:04d}',
'text': f'This is test paragraph {i*3+2} for concurrent translation testing.',
'length': 60
},
{
'global_id': f'p_{i*3+3:04d}',
'text': f'This is test paragraph {i*3+3} for concurrent translation testing.',
'length': 60
}
]
test_chunks.append(chunk)
print(f"📦 创建了 {len(test_chunks)} 个测试chunks")
print(f"⚙️ 并发限制: {config['openrouter']['rate_limits']['concurrent_requests']}")
# 初始化客户端
llm_client = OpenRouterClient(config)
# 方法1: 串行翻译(原有方式)
print(f"\n📊 方法1: 串行翻译")
print("-" * 60)
start_time = time.time()
serial_results = []
for i, chunk in enumerate(test_chunks, 1):
result = await llm_client.translate_chunk_with_ids(chunk, model_type="test")
serial_results.append(result)
print(f" 完成 {i}/{len(test_chunks)}")
serial_time = time.time() - start_time
print(f"⏱️ 串行耗时: {serial_time:.2f}")
# 方法2: 并发翻译(新方式)
print(f"\n📊 方法2: 并发翻译 (asyncio.gather)")
print("-" * 60)
start_time = time.time()
# 创建所有任务
tasks = [
llm_client.translate_chunk_with_ids(chunk, model_type="test")
for chunk in test_chunks
]
# 并发执行
concurrent_results = await asyncio.gather(*tasks, return_exceptions=True)
concurrent_time = time.time() - start_time
print(f"⏱️ 并发耗时: {concurrent_time:.2f}")
# 计算加速比
speedup = serial_time / concurrent_time if concurrent_time > 0 else 0
print(f"\n📈 性能对比")
print("-" * 60)
print(f" 串行耗时: {serial_time:.2f}")
print(f" 并发耗时: {concurrent_time:.2f}")
print(f" [green]加速比: {speedup:.2f}x[/green]")
print(f" 理论最大加速: {config['openrouter']['rate_limits']['concurrent_requests']}x")
# 验证结果一致性
print(f"\n🔍 验证结果")
print("-" * 60)
success_count = 0
for i, result in enumerate(concurrent_results):
if isinstance(result, dict) and not isinstance(result, Exception):
success_count += 1
print(f" 成功翻译: {success_count}/{len(concurrent_results)} 个chunks")
# 显示第一个chunk的翻译
if concurrent_results and isinstance(concurrent_results[0], dict):
first_result = concurrent_results[0]
print(f"\n 第一个chunk示例:")
for global_id, translation in list(first_result.items())[:2]:
print(f" [{global_id}] {translation[:60]}...")
await llm_client.close()
print(f"\n✅ 并发翻译测试完成!")
if speedup > 1.5:
print(f"[green]✅ 并发加速成功!加速比: {speedup:.2f}x[/green]")
else:
print(f"[yellow]⚠️ 并发加速不明显,可能受API限制影响[/yellow]")
except Exception as e:
print(f"\n❌ 测试失败: {e}")
logger.error(f"测试失败: {e}", exc_info=True)
async def test_rate_limiter():
"""测试RateLimiter的并发控制"""
print("\n🧪 测试RateLimiter并发控制")
print("=" * 60)
try:
config = load_config()
concurrent_limit = config['openrouter']['rate_limits']['concurrent_requests']
print(f"⚙️ 并发限制设置: {concurrent_limit}")
llm_client = OpenRouterClient(config)
# 创建大量任务
num_tasks = 20
print(f"📦 创建 {num_tasks} 个任务")
active_tasks = []
completed_tasks = []
async def monitored_task(task_id):
"""带监控的任务"""
print(f" 任务 {task_id} 开始执行")
active_tasks.append(task_id)
# 模拟翻译
test_chunk = [{
'global_id': f'p_{task_id:04d}',
'text': f'Test paragraph {task_id} for rate limiting.',
'length': 40
}]
try:
result = await llm_client.translate_chunk_with_ids(test_chunk, model_type="test")
completed_tasks.append(task_id)
active_tasks.remove(task_id)
print(f" 任务 {task_id} 完成 (当前活跃: {len(active_tasks)})")
return result
except Exception as e:
active_tasks.remove(task_id)
print(f" 任务 {task_id} 失败: {e}")
return None
# 创建任务
tasks = [monitored_task(i) for i in range(1, num_tasks + 1)]
# 并发执行
start_time = time.time()
results = await asyncio.gather(*tasks, return_exceptions=True)
total_time = time.time() - start_time
print(f"\n📊 执行结果")
print("-" * 60)
print(f" 总任务数: {num_tasks}")
print(f" 成功完成: {len(completed_tasks)}")
print(f" 总耗时: {total_time:.2f}")
print(f" 平均每任务: {total_time/num_tasks:.2f}")
await llm_client.close()
print(f"\n✅ RateLimiter测试完成!")
except Exception as e:
print(f"\n❌ 测试失败: {e}")
logger.error(f"测试失败: {e}", exc_info=True)
if __name__ == "__main__":
# 配置日志
logger.remove()
logger.add(
sys.stdout,
level="WARNING", # 只显示警告和错误
format="<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {message}"
)
print("\n🔧 并发翻译逻辑验证")
print("=" * 60)
# 测试1: 对比串行和并发
asyncio.run(test_concurrent_translation())
# 测试2: 验证RateLimiter
asyncio.run(test_rate_limiter())
print("\n" + "=" * 60)
print("📋 测试总结:")
print("1. ✅ 实现了真正的并发翻译(asyncio.gather")
print("2. ✅ RateLimiter的Semaphore正确限制并发数")
print("3. ✅ 加速比应该接近配置的concurrent_requests值")
print("4. ✅ 每个请求的tokens数量正常(1000+")
print("\n🚀 并发翻译已准备就绪!")
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
EPUB双语翻译程序 - 保守修复验证脚本
验证修复后的系统是否能正确处理EPUB构建
"""
import asyncio
import sys
import json
from pathlib import Path
# 添加项目根目录到 Python 路径
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from src.epub_parser import EPUBParser
from src.translator import EPUBTranslator
from src.utils import load_config
from loguru import logger
async def test_conservative_fix():
"""测试保守修复方案"""
print("🔧 开始验证保守修复方案...")
try:
# 加载配置
config = load_config()
# 查找测试用的EPUB文件
test_files = list(Path(".").glob("*.epub"))
if not test_files:
print("❌ 未找到测试用的EPUB文件")
return
test_epub = test_files[0]
print(f"📚 使用测试文件: {test_epub}")
# 初始化翻译器
translator = EPUBTranslator(config)
# 测试解析
print("\n📖 测试EPUB解析...")
parser = EPUBParser(str(test_epub))
content_items = parser.extract_translatable_content()
print(f"✅ 成功解析,找到 {len(content_items)} 个内容项目")
for i, item in enumerate(content_items[:3], 1): # 只显示前3个
print(f" {i}. {item['title']} ({item['type']})")
# 检查数据类型
print("\n🔍 检查数据类型...")
for i, item in enumerate(content_items[:2], 1):
original_item = item['item']
print(f" 项目 {i}: {type(original_item)} - {original_item.__class__.__name__}")
# 检查是否有get_content方法
if hasattr(original_item, 'get_content'):
print(f" ✅ 有 get_content 方法")
else:
print(f" ❌ 没有 get_content 方法")
# 检查是否有get_name方法
if hasattr(original_item, 'get_name'):
print(f" ✅ 有 get_name 方法: {original_item.get_name()}")
else:
print(f" ❌ 没有 get_name 方法")
# 测试翻译(只翻译第一个项目)
print("\n🚀 测试单个项目翻译...")
if content_items:
test_item = content_items[0]
# 模拟翻译结果
mock_translations = [
"这是第一段的模拟翻译。",
"这是第二段的模拟翻译。",
"这是第三段的模拟翻译。"
]
# 构造翻译数据
translated_content = [{
'original_item': test_item['item'],
'translations': mock_translations,
'title': test_item['title'],
'type': test_item['type'],
'stats': {
'total_paragraphs': len(mock_translations),
'total_chunks': 1,
'cache_hits': 0,
'failed_translations': 0
}
}]
# 测试双语EPUB构建
print("\n📖 测试双语EPUB构建...")
from src.bilingual_builder import BilingualEPUBBuilder
builder = BilingualEPUBBuilder(parser.book, config)
# 创建测试输出目录
test_output_dir = Path("test_output")
test_output_dir.mkdir(exist_ok=True)
try:
result_file = builder.create_bilingual_epub(translated_content, str(test_output_dir))
print(f"✅ 双语EPUB构建成功: {result_file}")
# 验证文件是否存在
if Path(result_file).exists():
file_size = Path(result_file).stat().st_size
print(f" 文件大小: {file_size / 1024:.1f} KB")
else:
print("❌ 输出文件不存在")
except Exception as e:
print(f"❌ 双语EPUB构建失败: {e}")
logger.error(f"构建失败详情: {e}")
print("\n🎉 保守修复验证完成!")
except Exception as e:
print(f"❌ 验证过程出错: {e}")
logger.error(f"验证失败: {e}")
async def test_full_translation_flow():
"""测试完整翻译流程(小规模)"""
print("\n🧪 测试完整翻译流程...")
try:
# 查找测试用的EPUB文件
test_files = list(Path(".").glob("*.epub"))
if not test_files:
print("❌ 未找到测试用的EPUB文件")
return
test_epub = test_files[0]
config = load_config()
# 修改配置以进行小规模测试
config['translation']['chunk_size'] = 1000 # 更小的chunk
config['translation']['concurrent_requests'] = 2 # 更少的并发
# 初始化翻译器
translator = EPUBTranslator(config)
print(f"📚 开始测试翻译: {test_epub}")
# 运行测试模式
result = await translator.translate_epub(str(test_epub), test_mode=True)
if result.get('status') == 'success':
print("✅ 测试模式成功完成")
# 显示测试结果
preface_result = result.get('preface', {})
chapter_result = result.get('chapter', {})
if preface_result.get('status') == 'success':
print(f" 序言翻译: ✅ (长度: {preface_result.get('translation_length', 0)})")
if chapter_result.get('status') == 'success':
print(f" 章节翻译: ✅ (段落数: {chapter_result.get('paragraph_count', 0)})")
else:
print(f"❌ 测试模式失败: {result.get('error', '未知错误')}")
except Exception as e:
print(f"❌ 完整流程测试失败: {e}")
logger.error(f"完整流程测试失败: {e}")
if __name__ == "__main__":
# 配置日志
logger.remove()
logger.add(sys.stdout, level="INFO", format="<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {message}")
print("🔧 EPUB翻译器 - 保守修复验证")
print("=" * 50)
# 运行保守修复测试
asyncio.run(test_conservative_fix())
# 运行完整流程测试
asyncio.run(test_full_translation_flow())
print("\n" + "=" * 50)
print("🎯 修复总结:")
print("1. ✅ 采用保守的EPUB构建策略")
print("2. ✅ 深度复制原书结构,最大程度保持完整性")
print("3. ✅ 修复了数据传递中的字段名错误")
print("4. ✅ 增强了错误处理和类型检查")
print("5. ✅ 保守地插入翻译,避免破坏原有格式")
print("\n🚀 保守修复验证完成!")
@@ -0,0 +1,117 @@
import asyncio
import sys
import random
from pathlib import Path
from loguru import logger
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent))
from src.epub_parser import EPUBParser
from src.text_processor import TextProcessor
from src.llm_client import OpenRouterClient
from src.bilingual_builder import BilingualEPUBBuilder
from src.utils import load_config, setup_logging
async def run_random_sample_test(epub_path: str):
print(f"🚀 Starting Random Sample Test on: {epub_path}")
print("=" * 60)
# 1. Setup
config = load_config()
setup_logging(config)
# 2. Extract
print("\n[1/5] Extracting content...")
parser = EPUBParser(epub_path)
content_items = parser.extract_all_content_items()
text_processor = TextProcessor(config)
all_paragraphs = []
paragraph_to_file_map = {}
for item in content_items:
paragraphs = text_processor.extract_paragraphs_with_global_id(
item['content'],
item['file_name']
)
for para in paragraphs:
paragraph_to_file_map[para['global_id']] = {
'file_name': item['file_name'],
'text': para['text'],
'html_element': para['html_element']
}
all_paragraphs.extend(paragraphs)
print(f"✅ Extracted {len(all_paragraphs)} paragraphs.")
# 3. Chunking
chunks = text_processor.create_chunks_by_size(all_paragraphs)
print(f"✅ Created {len(chunks)} chunks.")
if not chunks:
print("❌ No chunks created. Exiting.")
return
# 4. Random Sampling Translation
# Select 3 random chunks (or fewer if total chunks < 3)
sample_size = min(3, len(chunks))
# Ensure we pick distinct chunks
selected_indices = sorted(random.sample(range(len(chunks)), sample_size))
selected_chunks = [chunks[i] for i in selected_indices]
print(f"\n[2/5] Randomly selected {sample_size} chunks for translation:")
for i, idx in enumerate(selected_indices):
chunk = chunks[idx]
print(f" Sample {i+1}: Chunk #{idx+1} (IDs: {chunk[0]['global_id']} - {chunk[-1]['global_id']}) - {len(chunk)} paragraphs")
print("\n[3/5] Translating selected chunks...")
llm_client = OpenRouterClient(config)
translation_map = {}
for i, chunk in enumerate(selected_chunks):
print(f" Translating Sample {i+1}...")
# Use 'production' model to ensure real translation quality check, or 'test' if cost is concern
# Using 'test' model usually implies a cheaper/faster model if configured, or same as prod.
# Assuming we want to see real results, we use the configured model.
chunk_translations = await llm_client.translate_chunk_with_ids(chunk, model_type="production")
translation_map.update(chunk_translations)
print(f" ✅ Sample {i+1} done. Got {len(chunk_translations)} translations.")
await llm_client.close()
# 5. Build Bilingual EPUB
print(f"\n[4/5] Building Bilingual EPUB with partial translations...")
print(f" Total translations to insert: {len(translation_map)}")
builder = BilingualEPUBBuilder(parser.book, config)
output_dir = "test_output"
Path(output_dir).mkdir(exist_ok=True)
try:
output_file = builder.create_bilingual_epub_with_mapping(
translation_map,
paragraph_to_file_map,
output_dir
)
print(f"\n[5/5] ✅ Success! Output saved to: {output_file}")
# Verify correctness by checking if the specific IDs we translated were actually used
# (This is manual verification via logs for now, as Builder logs "matched X/Y paragraphs")
print("\n🔍 Verification Check:")
print(" Check the logs above for 'src.bilingual_builder'.")
print(" You should see 'ID匹配: p_XXXX -> ...' for the IDs in our samples.")
print(" For files NOT in our samples, you should see '没有匹配到任何段落'.")
except Exception as e:
print(f"\n❌ Build Failed: {e}")
logger.error("Build failed", exc_info=True)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python test_full_flow_random_sample.py <epub_file>")
sys.exit(1)
epub_file = sys.argv[1]
asyncio.run(run_random_sample_test(epub_file))
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
全局编号系统验证脚本
测试重构后的翻译流程和中英文对应关系
"""
import asyncio
import sys
from pathlib import Path
# 添加项目根目录到路径
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from src.epub_parser import EPUBParser
from src.text_processor import TextProcessor
from src.llm_client import OpenRouterClient
from src.translator import EPUBTranslator
from src.utils import load_config
from loguru import logger
async def test_global_id_system():
"""测试全局ID系统"""
print("🔧 测试全局ID系统")
print("=" * 60)
try:
config = load_config()
# 查找测试EPUB
test_files = list(Path(".").glob("*.epub"))
if not test_files:
print("❌ 未找到测试EPUB文件")
return
test_epub = test_files[0]
print(f"📚 测试文件: {test_epub}\n")
# 测试1: 段落提取和编号
print("📝 测试1: 段落提取和全局编号")
print("-" * 60)
parser = EPUBParser(str(test_epub))
content_items = parser.extract_all_content_items()
if not content_items:
print("❌ 未找到内容项目")
return
# 提取段落
text_processor = TextProcessor(config)
all_paragraphs = []
for item in content_items[:2]: # 只测试前2个文件
paragraphs = text_processor.extract_paragraphs_with_global_id(
item['content'],
item['file_name']
)
all_paragraphs.extend(paragraphs)
print(f"✅ 提取了 {len(all_paragraphs)} 个段落")
print(f" ID范围: {all_paragraphs[0]['global_id']} - {all_paragraphs[-1]['global_id']}")
# 显示前3个段落
print(f"\n 前3个段落示例:")
for para in all_paragraphs[:3]:
print(f" [{para['global_id']}] {para['text'][:60]}...")
# 测试2: 分块
print(f"\n📦 测试2: 智能分块(不切断段落)")
print("-" * 60)
chunks = text_processor.create_chunks_by_size(all_paragraphs)
print(f"✅ 创建了 {len(chunks)} 个chunk")
print(f" Chunk大小限制: {config['translation']['chunk_size']} 字符")
# 显示每个chunk的信息
for i, chunk in enumerate(chunks, 1):
chunk_size = sum(p['length'] for p in chunk)
print(f" Chunk {i}: {len(chunk)} 段落, {chunk_size} 字符, "
f"ID: {chunk[0]['global_id']}-{chunk[-1]['global_id']}")
# 验证chunk不跨越段落
print(f"\n 验证: 检查chunk是否保持段落完整性...")
for i, chunk in enumerate(chunks, 1):
if not chunk:
print(f" ❌ Chunk {i} 为空")
continue
# 检查每个段落是否完整
for para in chunk:
if para['length'] == 0:
print(f" ❌ 发现空段落: {para['global_id']}")
else:
print(f" ✅ Chunk {i} 段落完整")
break
# 测试3: 翻译一个小chunk
print(f"\n🚀 测试3: 翻译示例chunk(带编号)")
print("-" * 60)
if chunks:
# 选择第一个chunk的前3个段落
test_chunk = chunks[0][:3]
print(f" 测试 {len(test_chunk)} 个段落:")
for para in test_chunk:
print(f" [{para['global_id']}] {para['text'][:50]}...")
llm_client = OpenRouterClient(config)
print(f"\n 发送翻译请求...")
translations = await llm_client.translate_chunk_with_ids(
test_chunk,
model_type="test"
)
print(f"\n 翻译结果:")
for para in test_chunk:
global_id = para['global_id']
translation = translations.get(global_id, "[未找到翻译]")
print(f"\n [{global_id}]")
print(f" EN: {para['text'][:80]}...")
print(f" ZH: {translation[:80]}...")
# 验证对应关系
if translation.startswith('[翻译失败') or translation == "[未找到翻译]":
print(f" ❌ 翻译失败")
else:
print(f" ✅ 翻译成功")
await llm_client.close()
# 测试4: 统计信息
print(f"\n📊 测试4: 统计信息")
print("-" * 60)
stats = text_processor.get_statistics(all_paragraphs)
print(f" 总段落数: {stats['total_paragraphs']}")
print(f" 总字符数: {stats['total_characters']}")
print(f" 平均长度: {stats['average_length']}")
print(f" 最短段落: {stats['min_length']} 字符")
print(f" 最长段落: {stats['max_length']} 字符")
print(f"\n🎉 全局ID系统测试完成!")
except Exception as e:
print(f"\n❌ 测试失败: {e}")
logger.error(f"测试失败: {e}", exc_info=True)
async def test_full_flow():
"""测试完整翻译流程"""
print("\n" + "=" * 60)
print("🧪 测试完整翻译流程(测试模式)")
print("=" * 60)
try:
config = load_config()
# 查找测试EPUB
test_files = list(Path(".").glob("*.epub"))
if not test_files:
print("❌ 未找到测试EPUB文件")
return
test_epub = test_files[0]
# 创建翻译器
translator = EPUBTranslator(config, use_cache=True)
# 运行测试模式
result = await translator.translate_epub(str(test_epub), test_mode=True)
if result.get('status') == 'success':
print(f"\n✅ 测试模式成功")
print(f" 测试段落数: {result.get('tested_paragraphs', 0)}")
else:
print(f"\n❌ 测试模式失败: {result.get('error', '未知错误')}")
except Exception as e:
print(f"\n❌ 测试失败: {e}")
logger.error(f"测试失败: {e}", exc_info=True)
if __name__ == "__main__":
# 配置日志
logger.remove()
logger.add(
sys.stdout,
level="INFO",
format="<green>{time:HH:mm:ss}</green> | <level>{level}</level> | {message}"
)
print("\n🔧 EPUB翻译器 - 全局编号系统验证")
print("=" * 60)
# 运行测试
asyncio.run(test_global_id_system())
asyncio.run(test_full_flow())
print("\n" + "=" * 60)
print("📋 验证总结:")
print("1. ✅ 全局唯一ID系统")
print("2. ✅ 智能分块(不切断段落)")
print("3. ✅ 带编号的LLM翻译")
print("4. ✅ 精确的ID到翻译映射")
print("5. ✅ 统计信息完整")
print("\n🚀 系统已准备就绪,可以开始正式翻译!")
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
更新后的测试脚本
测试新的缓存和智能分块功能
"""
import asyncio
import sys
from pathlib import Path
# 添加 src 目录到路径
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
async def test_new_features():
"""测试新功能"""
console = Console()
epub_file = "The ingenuity gap - Facing the economic, environmental, and other challenges of an increasingly complex and unpredictable future_副本.epub"
if not Path(epub_file).exists():
console.print(f"[red]文件不存在: {epub_file}[/red]")
return
try:
# 加载配置
config = load_config('config/config.json')
setup_logging(config)
console.print("[bold blue]测试新的 EPUB 翻译功能[/bold blue]\n")
# 测试1: 缓存功能
console.print("[cyan]1. 测试缓存功能...[/cyan]")
# 启用缓存的翻译器
translator_with_cache = EPUBTranslator(config, use_cache=True)
console.print("[yellow]首次运行(无缓存)...[/yellow]")
result1 = await translator_with_cache.translate_epub(epub_file, test_mode=True)
console.print("[yellow]第二次运行(应该使用缓存)...[/yellow]")
result2 = await translator_with_cache.translate_epub(epub_file, test_mode=True)
# 检查缓存命中
if (result1.get('preface', {}).get('from_cache') or
result1.get('chapter', {}).get('from_cache')):
console.print("[green]✓ 缓存功能正常工作[/green]")
else:
console.print("[yellow]缓存可能是首次使用[/yellow]")
# 测试2: 禁用缓存
console.print("\n[cyan]2. 测试禁用缓存...[/cyan]")
translator_no_cache = EPUBTranslator(config, use_cache=False)
result3 = await translator_no_cache.translate_epub(epub_file, test_mode=True)
console.print("[green]✓ 禁用缓存功能正常[/green]")
# 测试3: 智能分块
console.print("\n[cyan]3. 测试智能分块功能...[/cyan]")
from src.epub_parser import EPUBParser
from src.text_processor import TextProcessor
parser = EPUBParser(epub_file)
processor = TextProcessor(config)
# 获取第一个章节
content_items = parser.extract_translatable_content(['chapter'])
if content_items:
first_chapter = content_items[0]
paragraphs = processor.extract_paragraphs(first_chapter['content'])
console.print(f"章节段落数: {len(paragraphs)}")
# 测试智能分块
chunks = processor.create_smart_chunks(paragraphs)
console.print(f"生成翻译块数: {len(chunks)}")
console.print(f"配置的块大小: {config['translation']['chunk_size']} 字符")
# 显示块统计
for i, chunk in enumerate(chunks[:3], 1): # 只显示前3个
total_chars = sum(len(p['text']) for p in chunk)
console.print(f"{i}: {len(chunk)} 个段落, {total_chars} 字符")
console.print("[green]✓ 智能分块功能正常[/green]")
# 测试4: 翻译失败处理
console.print("\n[cyan]4. 测试翻译失败处理...[/cyan]")
# 这里我们可以模拟一个翻译失败的情况
# 通过检查配置中的 never_fallback_to_original 设置
never_fallback = config['translation'].get('never_fallback_to_original', True)
console.print(f"翻译失败时不回填原文: {'' if never_fallback else ''}")
if never_fallback:
console.print("[green]✓ 翻译失败处理配置正确[/green]")
else:
console.print("[yellow]⚠ 建议启用 never_fallback_to_original[/yellow]")
# 显示缓存统计
console.print("\n[cyan]5. 缓存统计信息...[/cyan]")
if translator_with_cache.cache:
stats = translator_with_cache.cache.get_cache_stats()
console.print(f"缓存文件数: {stats.get('total_files', 0)}")
console.print(f"缓存大小: {stats.get('total_size_mb', 0)} MB")
console.print(f"缓存目录: {stats.get('cache_directory', '')}")
console.print("\n[bold green]🎉 所有新功能测试完成![/bold green]")
# 询问是否进行完整翻译
console.print("\n[yellow]是否进行完整翻译测试?这将使用缓存加速翻译过程。[/yellow]")
response = input("输入 'yes' 继续完整翻译,其他任意键退出: ")
if response.lower() == 'yes':
console.print("\n[cyan]开始完整翻译(使用缓存)...[/cyan]")
output_file = await translator_with_cache.translate_epub(epub_file, test_mode=False)
console.print(f"\n[bold green]🎉 翻译完成![/bold green]")
console.print(f"输出文件: {output_file}")
else:
console.print("[yellow]已跳过完整翻译[/yellow]")
except Exception as e:
console.print(f"[red]测试过程出错: {e}[/red]")
import traceback
console.print(traceback.format_exc())
if __name__ == "__main__":
asyncio.run(test_new_features())
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
简单翻译测试
专门测试你的 EPUB 文件
"""
import asyncio
import sys
from pathlib import Path
# 添加 src 目录到路径
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
async def test_specific_epub():
"""测试特定的 EPUB 文件"""
console = Console()
epub_file = "The ingenuity gap - Facing the economic, environmental, and other challenges of an increasingly complex and unpredictable future_副本.epub"
if not Path(epub_file).exists():
console.print(f"[red]文件不存在: {epub_file}[/red]")
return
try:
# 加载配置
config = load_config('config/config.json')
setup_logging(config)
# 初始化翻译器
translator = EPUBTranslator(config)
console.print("[bold blue]测试 EPUB 翻译功能[/bold blue]\n")
# 显示书籍信息
console.print("[cyan]正在分析书籍结构...[/cyan]")
# 运行测试翻译
console.print("[yellow]开始测试翻译...[/yellow]")
result = await translator.translate_epub(epub_file, test_mode=True)
if isinstance(result, dict):
console.print("\n[bold green]测试结果:[/bold green]")
# 序言测试结果
preface_result = result.get('preface', {})
if preface_result.get('status') == 'success':
console.print("\n[green]✓ 序言翻译测试成功[/green]")
console.print(f"原文长度: {preface_result.get('original_length', 0)} 字符")
console.print(f"译文长度: {preface_result.get('translation_length', 0)} 字符")
console.print(f"原文预览: {preface_result.get('original', '')[:100]}...")
console.print(f"译文预览: {preface_result.get('translation', '')[:100]}...")
else:
console.print(f"[yellow]序言测试: {preface_result.get('reason', '失败')}[/yellow]")
# 章节测试结果
chapter_result = result.get('chapter', {})
if chapter_result.get('status') == 'success':
console.print("\n[green]✓ 章节翻译测试成功[/green]")
console.print(f"章节: {chapter_result.get('chapter_title', '')}")
console.print(f"原文长度: {chapter_result.get('original_length', 0)} 字符")
console.print(f"译文长度: {chapter_result.get('translation_length', 0)} 字符")
console.print(f"原文预览: {chapter_result.get('original', '')[:100]}...")
console.print(f"译文预览: {chapter_result.get('translation', '')[:100]}...")
else:
console.print(f"[yellow]章节测试: {chapter_result.get('reason', '失败')}[/yellow]")
if (preface_result.get('status') == 'success' or
chapter_result.get('status') == 'success'):
console.print("\n[bold green]🎉 翻译测试成功!可以进行完整翻译。[/bold green]")
# 询问是否进行完整翻译
console.print("\n[yellow]是否进行完整翻译?这可能需要较长时间和一定费用。[/yellow]")
response = input("输入 'yes' 继续完整翻译,其他任意键退出: ")
if response.lower() == 'yes':
console.print("\n[cyan]开始完整翻译...[/cyan]")
output_file = await translator.translate_epub(epub_file, test_mode=False)
console.print(f"\n[bold green]🎉 翻译完成![/bold green]")
console.print(f"输出文件: {output_file}")
else:
console.print("[yellow]已取消完整翻译[/yellow]")
else:
console.print("\n[red]翻译测试失败,请检查 API Key 和网络连接[/red]")
else:
console.print(f"[red]测试失败: {result}[/red]")
except Exception as e:
console.print(f"[red]测试过程出错: {e}[/red]")
import traceback
console.print(traceback.format_exc())
if __name__ == "__main__":
asyncio.run(test_specific_epub())
@@ -0,0 +1,110 @@
from bs4 import BeautifulSoup
from unittest.mock import MagicMock
from src.bilingual_builder import BilingualEPUBBuilder
from src.chinese_builder import ChineseEPUBBuilder
# Mock classes to avoid full EPUB dependencies
class MockItem:
def __init__(self, name, content):
self.name = name
self.content = content
self.title = "Mock Title"
self.id = "item_1"
def get_name(self): return self.name
def get_content(self): return self.content.encode('utf-8')
def get_type(self): return 9 # ITEM_DOCUMENT
def test_bilingual_builder_misalignment():
"""
Reproduces the off-by-one misalignment bug.
Scenario:
1. HTML contains: [Para1], [Nav], [Para2]
2. Ordered IDs passed to builder: [ID1, ID2] (assuming Nav is validly ignored by extractor logic but maybe ID list is different?
Actually, let's trace the bug logic:
Extractor:
- Para1 -> Clean -> Valid -> Added to Manifest (Status: Pending) -> ID1
- Nav -> Clean -> Valid -> Added to Manifest (Status: Ignored) -> ID2
- Para2 -> Clean -> Valid -> Added to Manifest (Status: Pending) -> ID3
Builder Input:
- translation_map: {ID1: "Trans1", ID3: "Trans3"} (Nav ignored so no trans)
- paragraph_map: {ID1: ..., ID2: ..., ID3: ...}
- ordered_ids: [ID1, ID2, ID3] (All items in file)
Builder Loop (Current Broken Logic):
- Scans Para1: Valid, Not Nav.
- Match with ordered_ids[0] (ID1). OK.
- Incr index -> 1.
- Scans Nav: is_navigation_element() == True -> CONTINUE
- Index remains 1.
- Scans Para2: Valid, Not Nav.
- Match with ordered_ids[1] (ID2).
- ID2 is the Nav item!
- translation_map.get(ID2) -> None (or wrong if ID2 had a translation).
- Result: Para2 gets NO translation or WRONG translation.
- Expected: Para2 should match ID3.
"""
# Setup
html_content = """
<html>
<body>
<p>Paragraph 1</p>
<div class="nav">Navigation Content</div>
<p>Paragraph 2</p>
</body>
</html>
"""
mock_item = MockItem("test.xhtml", html_content)
# IDs corresponding to the elements as they would be in Manifest
# ID1: Para1, ID2: Nav, ID3: Para2
ordered_ids = ["p_001", "p_002", "p_003"]
translation_map = {
"p_001": "翻译1",
"p_003": "翻译2" # p_002 is ignored, so no translation
}
# Config
config = {'output': {}}
mock_book = MagicMock()
mock_book.get_metadata.return_value = None
builder = BilingualEPUBBuilder(mock_book, config)
# Execute private method directly for testing
# We mock _add_style_link to do nothing
builder._add_style_link = MagicMock()
new_item = builder._create_bilingual_document(mock_item, ordered_ids, translation_map)
new_content = new_item.get_content().decode('utf-8')
soup = BeautifulSoup(new_content, 'html.parser')
# Analyze results
paragraphs = soup.find_all('p', class_='translation-text')
print(f"Generated Paragraphs: {len(paragraphs)}")
for p in paragraphs:
print(f" - {p.get_text()}")
# Assertions
# We expect 2 translated paragraphs.
# Current BUG: Likely only 1 found (Para1), and Para2 missed because it matched with p_002 which has no translation.
assert len(paragraphs) == 2, f"Expected 2 translated paragraphs, found {len(paragraphs)}"
assert paragraphs[0].get_text() == "翻译1"
assert paragraphs[1].get_text() == "翻译2"
if __name__ == "__main__":
try:
test_bilingual_builder_misalignment()
print("Test PASSED")
except AssertionError as e:
print(f"Test FAILED: {e}")
except Exception as e:
print(f"Test ERROR: {e}")
+150
View File
@@ -0,0 +1,150 @@
"""
EPUB 解析器测试
"""
import pytest
import tempfile
import os
from pathlib import Path
from ebooklib import epub
from src.epub_parser import EPUBParser
class TestEPUBParser:
"""EPUB 解析器测试类"""
@pytest.fixture
def sample_epub(self):
"""创建测试用的 EPUB 文件"""
# 创建临时 EPUB 文件
with tempfile.NamedTemporaryFile(suffix='.epub', delete=False) as tmp_file:
# 创建简单的 EPUB
book = epub.EpubBook()
book.set_identifier('test123')
book.set_title('Test Book')
book.set_language('en')
book.add_author('Test Author')
# 添加章节
c1 = epub.EpubHtml(
title='Chapter 1',
file_name='chap_01.xhtml',
lang='en'
)
c1.content = '''
<html>
<head><title>Chapter 1</title></head>
<body>
<h1>Chapter 1</h1>
<p>This is the first paragraph of the first chapter.</p>
<p>This is the second paragraph with more content to test parsing.</p>
</body>
</html>
'''
book.add_item(c1)
# 添加序言
preface = epub.EpubHtml(
title='Preface',
file_name='preface.xhtml',
lang='en'
)
preface.content = '''
<html>
<head><title>Preface</title></head>
<body>
<h1>Preface</h1>
<p>This is the preface of the book.</p>
<p>It contains important background information.</p>
</body>
</html>
'''
book.add_item(preface)
# 设置目录
book.toc = (
epub.Link("preface.xhtml", "Preface", "preface"),
epub.Link("chap_01.xhtml", "Chapter 1", "chap_01"),
)
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
book.spine = ['nav', preface, c1]
# 写入文件
epub.write_epub(tmp_file.name, book, {})
yield tmp_file.name
# 清理
os.unlink(tmp_file.name)
def test_parser_initialization(self, sample_epub):
"""测试解析器初始化"""
parser = EPUBParser(sample_epub)
assert parser.epub_path.exists()
assert parser.book is not None
assert parser.metadata['title'] == 'Test Book'
assert parser.metadata['author'] == 'Test Author'
def test_extract_metadata(self, sample_epub):
"""测试元数据提取"""
parser = EPUBParser(sample_epub)
assert parser.metadata['title'] == 'Test Book'
assert parser.metadata['author'] == 'Test Author'
assert parser.metadata['language'] == 'en'
def test_parse_toc(self, sample_epub):
"""测试目录解析"""
parser = EPUBParser(sample_epub)
assert parser.toc_structure['preface'] is not None
assert len(parser.toc_structure['chapters']) >= 1
assert parser.toc_structure['preface']['title'] == 'Preface'
def test_extract_translatable_content(self, sample_epub):
"""测试可翻译内容提取"""
parser = EPUBParser(sample_epub)
content_items = parser.extract_translatable_content()
assert len(content_items) >= 1
assert any(item['type'] == 'preface' for item in content_items)
assert any(item['type'] == 'chapter' for item in content_items)
def test_get_preface_content(self, sample_epub):
"""测试序言内容获取"""
parser = EPUBParser(sample_epub)
preface_text = parser.get_preface_content()
assert len(preface_text) > 0
assert 'preface' in preface_text.lower()
assert 'background information' in preface_text
def test_sample_content_for_prompt(self, sample_epub):
"""测试内容采样"""
parser = EPUBParser(sample_epub)
samples = parser.sample_content_for_prompt(ratio=0.5)
assert isinstance(samples, list)
assert len(samples) >= 0
def test_get_book_info(self, sample_epub):
"""测试书籍信息获取"""
parser = EPUBParser(sample_epub)
book_info = parser.get_book_info()
assert 'title' in book_info
assert 'author' in book_info
assert 'chapter_count' in book_info
assert book_info['title'] == 'Test Book'
assert book_info['has_preface'] is True
def test_nonexistent_file(self):
"""测试不存在的文件"""
with pytest.raises(FileNotFoundError):
EPUBParser('nonexistent.epub')
+188
View File
@@ -0,0 +1,188 @@
"""
翻译器测试
"""
import pytest
import asyncio
from unittest.mock import Mock, AsyncMock, patch
from src.translator import EPUBTranslator
from src.llm_client import OpenRouterClient
class TestEPUBTranslator:
"""EPUB 翻译器测试类"""
@pytest.fixture
def mock_config(self):
"""模拟配置"""
return {
'openrouter': {
'api_key': 'test_key',
'base_url': 'https://openrouter.ai/api/v1',
'models': {
'test': 'google/gemini-2.0-flash-exp',
'production': 'google/gemini-exp-1206'
},
'rate_limits': {
'requests_per_minute': 60,
'concurrent_requests': 5
}
},
'translation': {
'chunk_size': 3,
'max_context_length': 8000,
'sample_ratio': 0.1,
'target_language': 'zh-CN',
'temperature': 0.3,
'max_tokens': 4000
},
'processing': {
'skip_sections': ['acknowledgments'],
'include_sections': ['preface', 'chapter'],
'clean_patterns': ['\\[\\d+\\]'],
'min_paragraph_length': 20
},
'output': {
'format': 'bilingual',
'filename_suffix': '_bilingual',
'preserve_images': True,
'preserve_css': True,
'output_dir': 'output'
},
'logging': {
'level': 'INFO',
'file': 'logs/test.log'
}
}
@pytest.fixture
def mock_translator(self, mock_config):
"""创建模拟翻译器"""
with patch('src.translator.OpenRouterClient') as mock_client:
mock_client.return_value.close = AsyncMock()
translator = EPUBTranslator(mock_config)
return translator
def test_translator_initialization(self, mock_translator):
"""测试翻译器初始化"""
assert mock_translator.config is not None
assert mock_translator.text_processor is not None
assert mock_translator.console is not None
@pytest.mark.asyncio
async def test_get_translation_estimate(self, mock_translator):
"""测试翻译估算"""
# 模拟 EPUBParser
with patch('src.translator.EPUBParser') as mock_parser:
mock_parser.return_value.extract_translatable_content.return_value = [
{
'title': 'Test Chapter',
'content': '<p>Test paragraph content</p>' * 10,
'type': 'chapter'
}
]
# 模拟 text_processor
mock_translator.text_processor.extract_paragraphs = Mock(return_value=[
{'text': 'Test paragraph content'} for _ in range(10)
])
estimate = await mock_translator.get_translation_estimate('test.epub')
assert 'total_paragraphs' in estimate
assert 'estimated_tokens' in estimate
assert 'estimated_time_minutes' in estimate
def test_get_translator_info(self, mock_translator):
"""测试获取翻译器信息"""
# 模拟 llm_client
mock_translator.llm_client.get_model_info = Mock(return_value={
'test_model': 'test_model',
'production_model': 'prod_model'
})
info = mock_translator.get_translator_info()
assert 'version' in info
assert 'llm_models' in info
assert 'config' in info
assert info['config']['chunk_size'] == 3
class TestOpenRouterClient:
"""OpenRouter 客户端测试类"""
@pytest.fixture
def mock_config(self):
"""模拟配置"""
return {
'openrouter': {
'api_key': 'test_key',
'base_url': 'https://openrouter.ai/api/v1',
'models': {
'test': 'google/gemini-2.0-flash-exp',
'production': 'google/gemini-exp-1206'
},
'rate_limits': {
'requests_per_minute': 60,
'concurrent_requests': 5
}
},
'translation': {
'temperature': 0.3,
'max_tokens': 4000
}
}
def test_client_initialization_invalid_key(self, mock_config):
"""测试无效 API Key"""
mock_config['openrouter']['api_key'] = 'YOUR_OPENROUTER_API_KEY'
with pytest.raises(ValueError, match="请在配置文件中设置有效的 OpenRouter API Key"):
OpenRouterClient(mock_config)
@patch('src.llm_client.AsyncOpenAI')
def test_client_initialization_valid(self, mock_openai, mock_config):
"""测试有效初始化"""
client = OpenRouterClient(mock_config)
assert client.models['test'] == 'google/gemini-2.0-flash-exp'
assert client.models['production'] == 'google/gemini-exp-1206'
mock_openai.assert_called_once()
@patch('src.llm_client.AsyncOpenAI')
def test_build_translation_prompt(self, mock_openai, mock_config):
"""测试翻译提示词构建"""
client = OpenRouterClient(mock_config)
prompt = client.build_translation_prompt(
"Hello world",
"This is a test book",
{"technical_terms": {"API": "应用程序接口"}}
)
assert "Hello world" in prompt
assert "This is a test book" in prompt
assert "API -> 应用程序接口" in prompt
@patch('src.llm_client.AsyncOpenAI')
def test_split_translation_result(self, mock_openai, mock_config):
"""测试翻译结果分割"""
client = OpenRouterClient(mock_config)
# 测试正常分割
translation = "第一段翻译\n\n第二段翻译\n\n第三段翻译"
result = client._split_translation_result(translation, 3)
assert len(result) == 3
assert result[0] == "第一段翻译"
assert result[1] == "第二段翻译"
assert result[2] == "第三段翻译"
# 测试单段落
single_translation = "单段落翻译"
result = client._split_translation_result(single_translation, 1)
assert len(result) == 1
assert result[0] == "单段落翻译"