网页沉浸式双语翻译的原理
Emacs
EAF
2023-05-06 5266字

我平常阅读技术文档时, 经常会使用 “沉浸式翻译” 这个 Chrome 插件, 它大大提高了我的英文阅读效率, 它相对于其他翻译插件的优点有几个:

  1. 双语翻译: 一段英文一段中文, 相对于 Google 全部翻译成中文的好处是, 万一哪一段翻译错了, 往上瞟一眼英文就知道正确的意思
  2. 全文翻译: 因为有些老外写的文章喜欢用一些高级词汇, 其实意思是一样的, 一个一个鼠标或者快捷键查看词典太费时
  3. 风格一致: 中文和英文的展示样式一致, 不会因为翻译影响页面布局和阅读的流畅性

但是外部浏览器和我最爱的 Emacs 有时候互通数据比较麻烦, 如果可以给 EAF Browser 实现一个类似的插件, 我写代码效率就更高了。

最开始本来想看“沉浸式翻译”的源代码的, 但是看了看洋洋洒洒 2 万多行, 算了, 懒得分析, 自己从零开始写还要简单一点, 下面是流程思路:

  1. 抓取节点: 抓取页面上所有文本节点
  2. 过滤节点: 对一些不需要翻译的节点进行过滤, 比如空字符串、 单个字母、 按钮和特定网站的控件
  3. 标记节点: 事先在这些需要翻译的文本节点后面添加一些隐藏兄弟节点, 并插入一些特殊的 class 作为标记, 比如 eaf-translated
  4. 翻译节点: 汇总所有文本节点的内容为字符串列表, 发给翻译服务
  5. 更新节点: 翻译后的文字再传入浏览器中, 用一个 for 循环结合第三步的 class 标记, 依次回填 innerHTML 即可

下面是一些源代码分享, 因为都比较简单, 我就不一行一行的讲解了:

EAF 调用翻译命令

@interactive(insert_or_do=True)
def immersive_translation(self):
    if shutil.which(crow):
        if self.immersive_translation_js is None:
            self.immersive_translation_js = self.read_js_content(immersive_translation.js)

        translate_list = self.execute_js(self.immersive_translation_js)
        thread = TranslateThread(translate_list)
        thread.fetch_result.connect(self.handle_immersive_translation_result)
        self.thread_queue.append(thread)
        thread.start()

        message_to_emacs(Translate...)
    else:
        message_to_emacs(Please install the translation tool crow before experiencing immersive translation.)

上面这段代码主要是在 Python 子线程中运行翻译命令, 避免卡住 Emacs。

获取翻译节点内容

(function() {
    function getTextNodes(node, nodes = []) {
        if (node.nodeType === Node.TEXT_NODE &&
            node.textContent.trim()) {
                nodes.push(node);
            } else {
                for (const child of node.childNodes) {
                    getTextNodes(child, nodes);
                }
            }
        return nodes;
    }

    function isNumeric(str) {
        return /^\d+$/.test(str);
    }

    function checkString(input) {
        const regex = /^[\d.,!?;:-\s]+$/
        return regex.test(input);
    }

    function addTranslations() {
        const textNodes = getTextNodes(document.body);
        var pageUrl = window.location.href;

        let index = 0;
        let nodeTexts = [];
        for (const textNode of textNodes) {
            const textContent = textNode.textContent;
            const text = textContent.trim();

            if (text.length === 0 ||
                text.length === 1 ||
                checkString(textContent) ||
                ([nil].includes(text)) ||
                textNode.parentNode.tagName === BUTTON) {
                    continue;
                }

            if (pageUrl.startsWith(https://www.reddit.com”) &&
                (isNumeric(textContent) ||
                    textContent.startsWith(/r/) ||
                    textContent.startsWith(/u/) ||
                    textContent.startsWith(r/) ||
                    textContent.startsWith(u/) ||
                    textContent.startsWith(level ) ||
                    textContent.endsWith( ago) ||
                    ([give award, award, share, reply, cc, comment as, posted by, op,
                        report, save, follow].includes(text.toLowerCase())) ||
                    (textNode.className && textNode.className.includes(button)) ||
                    (textNode.className && textNode.className.includes(icon-comment))
                )) {
                    continue;
                }

            const translatedText = eaf-translated-node- + index;
            const translatedTextNode = document.createTextNode(“”);
            const translatedNode = document.createElement(div);

            translatedNode.appendChild(translatedTextNode);
            translatedNode.classList.add(eaf-translated);
            translatedNode.classList.add(translatedText);

            textNode.after(translatedNode);

            nodeTexts.push(textContent);

            index++;
        }

        return nodeTexts;
    }

    return addTranslations();
})();

这段代码其实很简单, 主要是对一些不需要翻译的节点进行过滤, 比如空字符串、 单个字母、 按钮和特定网站的控件, 以 Reddit 举例, 比如回复、 分享等常用按钮就不用再翻译啦。

翻译节点内容

class TranslateThread(QThread):

    fetch_result = QtCore.pyqtSignal(list)

    def __init__(self, texts):
        QThread.__init__(self)

        self.texts = texts

    def get_command_result(self, command_string):
        import subprocess
        process = subprocess.Popen(command_string, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        process.wait()
        result = process.stdout.readlines()
        return result

    def run(self):
        if self.texts is None:
            message_to_emacs(Not fetch words, please try agian.)
        else:
            separator = <meta name=google content=notranslate/>
            text = ‘’.join(list(map(lambda t: f{separator}{t}\n, self.texts)))
            self.cache_file = tempfile.NamedTemporaryFile(mode=w, delete=False)
            self.cache_file_path = self.cache_file.name

            with open(self.cache_file_path, w) as f:
                f.write(text)

            result = self.get_command_result(crow -t zh-CN --j -e google -f {}.format(self.cache_file_path))
            translation = json.loads(‘’.join(list(map(lambda b: b.decode(utf-8), result))))[translation]

            if os.path.exists(self.cache_file_path):
                os.remove(self.cache_file_path)

            if len(translation) > 0:
                translates = translation.split(separator)[1:]

                self.fetch_result.emit(translates)

用 QThread 子线程来跑翻译服务, 基于 crow 这个工具来调取 Google 翻译服务, 这里的关键技术是:

所有翻译节点的内容抓取后是一个字符串列表, 每个节点的内容就是一个字符串, 为了提高翻译效率, 我们需要一次性翻译整个字符串列表, 而不是每一个字符串一个翻译请求。 同时, 我们还要避免翻译后的内容混在一起无法剥离, 所以我们要找一个独特的分割符号让 Google 一定不会翻译分割符号。

最开始找的是一些小众的 Unicode 作为分隔符, 但是多次测试发现 Google 会选择性的去掉部分分隔符, 导致翻译后的字符串列表和节点序列不一致。

最后搜索研究了一下, 发现用 <meta name=‘google’ content=‘notranslate’/> 作为分割符号就非常完美, Google 一定不会翻译这个分隔符。

更新节点内容

(function() {
    let translates = %1;

    const elements = document.getElementsByClassName(eaf-translated);

    for (let i = 0; i < elements.length; i++) {
        const classNames = elements[i].classList;

        const targetClassName = Array.from(classNames).find((className) =>
            className.startsWith(eaf-translated-node-)
        );

        if (targetClassName) {
            const index = parseInt(targetClassName.split(-)[3]);

            let translatedNode = elements[i];

            translatedNode.style.display = block;
            translatedNode.style.whiteSpace = pre-wrap;
            translatedNode.style.borderLeft = 4px solid #C53D56 !important;
            translatedNode.style.paddingLeft = 12px !important;
            translatedNode.style.marginTop = 4px;
            translatedNode.style.marginBottom = 4px;
            translatedNode.style.paddingTop = 4px;
            translatedNode.style.paddingBottom = 4px;

            translatedNode.innerHTML = translates[index];
        }
    }
})();

最后这个函数很简单, 因为需要翻译的节点和翻译后的字符串列表是一一对应的, 根据翻译字符串列表对应的 index 来更新节点 innerHTML 值就好了, 大家也可以根据自己的需要, 在更新节点时调整一下翻译节点的样式。

最后

总共用了不到 200 行就实现了沉浸式翻译插件, 非常的方便。

Enjoy!