增加html工作流

This commit is contained in:
xunbu
2025-08-18 18:44:08 +08:00
parent 943c540edc
commit fd061960f0
11 changed files with 383 additions and 13 deletions

View File

@@ -0,0 +1,220 @@
import asyncio
from dataclasses import dataclass
from typing import Self, Literal, Set, Dict, List, Tuple
from bs4 import BeautifulSoup, NavigableString, Tag, Comment
from docutranslate.agents.segments_agent import SegmentsTranslateAgentConfig, SegmentsTranslateAgent
from docutranslate.ir.document import Document
from docutranslate.translator.ai_translator.base import AiTranslatorConfig
from docutranslate.translator.base import Translator
# --- 规则定义 ---
# 1. 不可翻译标签(黑名单)
# 这些标签及其内容在任何情况下都不应被翻译,因为它们通常包含代码、样式或元数据。
# 在预处理阶段,这些标签及其所有子元素将被直接从文档中移除,以确保它们不会被意外修改。
NON_TRANSLATABLE_TAGS: Set[str] = {
'script', # JavaScript代码
'style', # CSS样式
'pre', # 预格式化文本,通常用于代码块
'code', # 行内代码
'kbd', # 键盘输入
'samp', # 示例输出
'var', # 变量
'noscript',# script未启用时的内容
'meta', # 元数据
'link', # 外部资源链接
'head', # 文档头部,通常不包含可见的可翻译内容
}
# 2. 可翻译标签(白名单)
# 定义一组被认为是“安全”的HTML标签这些标签中的直接文本内容适合被翻译。
# 这种白名单策略与上面的黑名单结合,提供了双重保障。
SAFE_TAGS: Set[str] = {
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'li', 'blockquote', 'q', 'caption',
'span', 'a', 'strong', 'em', 'b', 'i', 'u',
'td', 'th',
'button', 'label', 'legend', 'option',
'figcaption', 'summary', 'details',
'div', # div 比较通用,但我们的逻辑只提取其顶层文本节点,相对安全
}
# 3. 可翻译属性(白名单)
# 定义一组“安全”的属性,这些属性的值通常是给用户看的可读文本。
# 格式为: { 'tag_name': ['attr1', 'attr2'], ... }
SAFE_ATTRIBUTES: Dict[str, List[str]] = {
'img': ['alt', 'title'],
'a': ['title'],
'input': ['placeholder', 'title'],
'textarea': ['placeholder', 'title'],
'abbr': ['title'],
'area': ['alt'],
# 对于所有标签title属性通常是可翻译的
'*': ['title']
}
@dataclass
class HtmlTranslatorConfig(AiTranslatorConfig):
"""
HTML翻译器的配置类。
Attributes:
insert_mode (Literal["replace", "append", "prepend"]):
指定如何插入翻译文本。
- "replace": 用译文替换原文。
- "append": 在原文后追加译文。
- "prepend": 在原文前追加译文。
separator (str): 在 "append""prepend" 模式下,用于分隔原文和译文的字符串。
"""
insert_mode: Literal["replace", "append", "prepend"] = "replace"
separator: str = " " # HTML中用空格作为默认分隔符可能更合适
class HtmlTranslator(Translator):
"""
一个用于翻译 HTML 文件内容的翻译器。
它采用黑白名单结合的策略,以最大程度地保留页面样式和功能:
1. 黑名单:首先,完全移除 script, style, code 等明确不可翻译的标签及其内容。
2. 白名单然后在剩余的HTML中只提取和翻译指定安全标签和属性中的文本内容。
3. 注释保护显式地跳过HTML注释确保它们不被翻译。
这种方法能有效避免破坏页面结构、脚本、样式和注释。
"""
def __init__(self, config: HtmlTranslatorConfig):
super().__init__(config=config)
self.chunk_size = config.chunk_size
agent_config = SegmentsTranslateAgentConfig(
custom_prompt=config.custom_prompt,
to_lang=config.to_lang,
baseurl=config.base_url,
key=config.api_key,
model_id=config.model_id,
system_prompt=None,
temperature=config.temperature,
thinking=config.thinking,
max_concurrent=config.concurrent,
timeout=config.timeout,
logger=self.logger
)
self.translate_agent = SegmentsTranslateAgent(agent_config)
self.insert_mode = config.insert_mode
self.separator = config.separator
def _pre_translate(self, document: Document) -> Tuple[BeautifulSoup, List[Dict], List[str]]:
"""
解析HTML文档根据规则提取所有需要翻译的文本节点和属性。
步骤:
1. 使用黑名单移除所有不可翻译的标签,从根本上防止它们被处理。
2. 遍历剩余的HTML元素根据白名单提取可翻译的文本和属性值同时跳过注释。
"""
soup = BeautifulSoup(document.content, 'lxml')
# 步骤 1: 移除所有不可翻译的标签及其内容
for tag in soup.find_all(NON_TRANSLATABLE_TAGS):
tag.decompose()
translatable_items = []
original_texts = []
# 步骤 2: 遍历所有剩余标签,提取可翻译内容
for tag in soup.find_all(True):
# --- 2a. 翻译安全标签内的文本节点 ---
if tag.name in SAFE_TAGS:
# 只处理标签的直接子节点中的文本,这是保留样式的关键。
for child in list(tag.children):
# 【关键修改】确保处理的是纯文本节点而不是注释Comment是NavigableString的子类
if isinstance(child, NavigableString) and not isinstance(child, Comment) and child.strip():
text = str(child)
translatable_items.append({'type': 'node', 'object': child})
original_texts.append(text)
# --- 2b. 翻译安全标签内的安全属性 ---
attributes_to_check = SAFE_ATTRIBUTES.get(tag.name, []) + SAFE_ATTRIBUTES.get('*', [])
for attr in set(attributes_to_check): # 使用set去重
if tag.has_attr(attr) and tag[attr].strip():
value = tag[attr]
translatable_items.append({'type': 'attribute', 'tag': tag, 'attribute': attr})
original_texts.append(value)
return soup, translatable_items, original_texts
def _after_translate(self, soup: BeautifulSoup, translatable_items: list,
translated_texts: list[str], original_texts: list[str]) -> bytes:
"""
将翻译后的文本写回到BeautifulSoup对象中对应的节点或属性并返回最终的HTML字节流。
"""
if len(translatable_items) != len(translated_texts):
self.logger.error("翻译前后的文本片段数量不匹配 (%d vs %d),跳过写入操作以防损坏文件。",
len(translatable_items), len(translated_texts))
return soup.encode('utf-8')
for i, item in enumerate(translatable_items):
translated_text = translated_texts[i]
original_text = original_texts[i]
new_content = ""
if self.insert_mode == "replace":
if item['type'] == 'node':
# 对于文本节点,保留原文前后的空白字符,这对维持内联元素的间距至关重要。
leading_space = original_text[:len(original_text) - len(original_text.lstrip())]
trailing_space = original_text[len(original_text.rstrip()):]
new_content = leading_space + translated_text + trailing_space
else: # 属性
new_content = translated_text
elif self.insert_mode == "append":
new_content = original_text + self.separator + translated_text
elif self.insert_mode == "prepend":
new_content = translated_text + self.separator + original_text
else:
self.logger.error(f"不正确的HtmlTranslatorConfig参数: insert_mode='{self.insert_mode}'")
new_content = original_text # 出错时恢复原文
# 根据类型将内容写回
if item['type'] == 'node':
node = item['object']
# 检查节点是否仍然在解析树中,以防在处理过程中被移动或删除
if node.parent:
node.replace_with(NavigableString(new_content))
elif item['type'] == 'attribute':
tag = item['tag']
attr = item['attribute']
tag[attr] = new_content
# 将修改后的BeautifulSoup对象编码为utf-8字节流
return soup.encode('utf-8')
def translate(self, document: Document) -> Self:
"""
同步翻译HTML文档。
"""
soup, translatable_items, original_texts = self._pre_translate(document)
if not translatable_items:
self.logger.info("\nHTML文件中没有找到符合安全规则的可翻译内容。")
# 即使没有翻译内容,也返回经过清理(移除非翻译标签)的文档内容
document.content = soup.encode('utf-8')
return self
translated_texts = self.translate_agent.send_segments(original_texts, self.chunk_size)
document.content = self._after_translate(soup, translatable_items, translated_texts, original_texts)
return self
async def translate_async(self, document: Document) -> Self:
"""
异步翻译HTML文档。
"""
soup, translatable_items, original_texts = await asyncio.to_thread(self._pre_translate, document)
if not translatable_items:
self.logger.info("\nHTML文件中没有找到符合安全规则的可翻译内容。")
document.content = await asyncio.to_thread(soup.encode, 'utf-8')
return self
translated_texts = await self.translate_agent.send_segments_async(original_texts, self.chunk_size)
document.content = await asyncio.to_thread(
self._after_translate, soup, translatable_items, translated_texts, original_texts
)
return self