【开源推荐】双击即译!我用 trae 打造了一款轻量级Chrome网页翻译插件

写在前面

在日常冲浪和技术学习中,我们常常会遇到需要翻译网页部分内容的情况。整页翻译虽然方便,但有时会破坏页面布局,翻译技术术语也不够精准。作为一个开发者,我希望能有一款"指哪打哪"的翻译工具。于是,我利用trae打造了一款小巧灵活的 Chrome 翻译插件,它通过双击或鼠标悬停的方式,实现精准的划词翻译。

一句话总结

这是一个对网页任意元素精准翻译的chrome 插件。

代码解读

在 background.js 中,最关键的部分是如何优雅地处理网络请求。

javascript 复制代码
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    console.log(`background`)
  if (request.action === 'translate') {
    console.log('Background收到翻译请求:', request.url); // 添加日志用于调试
    fetch(request.url)
      .then(response => {
        console.log('API响应状态:', response.status);
        return response.json();
      })
      .then(data => sendResponse({success: true, data}))
      .catch(error => {
        console.error('翻译请求错误:', error);
        sendResponse({success: false, error: error.message});
      });
    return true; // 保持消息通道开放以支持异步响应
  }
});

content.js是核心代码

javascript 复制代码
/**
 * 判断文本是否为英文
 * @param {string} text 要检测的文本
 * @param {object} options 可选参数
 * @param {boolean} options.allowMixed 是否允许混合少量非英文字符,默认不允许
 * @param {number} options.maxNonEnglish 允许的最大非英文字符比例(0-1),默认0
 * @returns {boolean} 是否为英文文本
 */
function isEnglishText(text, options = {}) {
  // 处理空值或非字符串输入
  if (!text || typeof text !== 'string') {
    return false;
  }

  // 解析参数
  const { allowMixed = false, maxNonEnglish = 0 } = options;
  
  // 英文文本的基本正则表达式(包含大小写字母、数字、常见英文标点和空白)
  const englishPattern = /^[a-zA-Z0-9\s!"#$%&'()*+,\-.\/:;<=>?@[\]^_`{|}~]+$/;
  
  // 严格模式:完全匹配英文模式
  if (!allowMixed) {
    return englishPattern.test(text);
  }
  
  // 宽松模式:允许一定比例的非英文字符
  const nonEnglishChars = text.match(/[^a-zA-Z0-9\s!"#$%&'()*+,\-.\/:;<=>?@[\]^_`{|}~]/g) || [];
  const nonEnglishRatio = nonEnglishChars.length / text.length;
  
  return nonEnglishRatio <= maxNonEnglish;
}

// 等待页面加载完成
console.log('等待页面加载完成,脚本开始执行,准备注册事件监听');
// document.addEventListener('DOMContentLoaded', processPage);
if (document.readyState === 'complete' || document.readyState === 'interactive') {
  // processPage();
  // 监听鼠标事件
  setupLinkHoverTranslation()
} else {
  // document.addEventListener('DOMContentLoaded', processPage);
}

// 监听DOM变化,处理动态加载内容
const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    if (mutation.addedNodes.length) {
      mutation.addedNodes.forEach(node => {
        if (mutation.nodeType === Node.ELEMENT_NODE) {
          processElement(node);
        }
      });
    }
  });
});

// 配置并启动观察器
observer.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: false,
  characterData: false
});

// 处理整个页面
function processPage() {
  const begin = new Date()
  console.log(`processPage 开始处理整个页面 ${begin}`)
  processElement(document.body);
}

// 处理单个元素
function processElement(element) {
  const textNodes = getTextNodes(element);
  textNodes.forEach(processTextNode);
}

// 获取所有文本节点
function getTextNodes(element) {
  const nodes = [];
  const ignoreTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'IMG', 'VIDEO', 'AUDIO'];

  function traverse(node) {
    if (node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0) {
      if (!ignoreTags.includes(node.parentNode.tagName)) {
        nodes.push(node);
      }
    } else if (node.nodeType === Node.ELEMENT_NODE && !ignoreTags.includes(node.tagName)) {
      node.childNodes.forEach(traverse);
    }
  }

  traverse(element);
  return nodes;
}

// 处理文本节点
function processTextNode(node) {
  const text = node.textContent.trim();
  if (text.length < 5) return; // 忽略过短文本

  // 使用franc库检测语言
//   console.log(`franc 检测语言 ${text}`)
  const lang = isEnglishText(text);
  if (lang) {
    // 检查是否已经翻译过
    if (node.nextSibling && node.nextSibling.classList?.contains('translation-result')) {
      return;
    }

    translateText(text,node)
  }
}

// 插入翻译结果
function insertTranslation(node, translatedText) {
  const translationElement = document.createElement('div');
  translationElement.className = 'translation-result';
  translationElement.textContent = translatedText;
  node.parentNode.insertBefore(translationElement, node.nextSibling);
}

// 生成随机数(百度翻译API需要)
function generateSalt() {
  return Math.floor(Math.random() * 10000000000);
}

// 调用百度翻译API
async function translateText(text,node) {
  // From storage get Baidu translate configuration
  const { baiduAppId, baiduSecretKey } = await chrome.storage.sync.get(['baiduAppId', 'baiduSecretKey']);
  if (!baiduAppId || !baiduSecretKey) {
    console.error('未设置百度翻译App ID或密钥,请在插件设置中配置');
    return '需要百度翻译App ID和密钥,请在插件设置中配置';
  }

  try {
    const salt = generateSalt();
    let sign = md5(baiduAppId + text + salt + baiduSecretKey);
    sign = sign.toLowerCase()
    const encodedText = encodeURIComponent(text);
    // Update the Baidu API URL from http to https
    const url = `https://api.fanyi.baidu.com/api/trans/vip/translate?q=${encodedText}&from=en&to=zh&appid=${baiduAppId}&salt=${salt}&sign=${sign}`;

      chrome.runtime.sendMessage({
        action: 'translate',
        url: url
    }, (response) => {
        // console.log('收到background响应:', response); // 添加日志
        if (response.success) {
          // 处理翻译结果(取消注释并完善)
          // console.log('翻译结果:', response.data);
          if(response.data.error_code=='54003' || response.data.error_code=='54001'){
            // 等待1s
            sleep(1000).then(()=>{
              translateText(text,node)
            })
          }else if(response.data.error_code=='54004'){
            console.log(response.data.error_msg)
          }else{
            // 将翻译结果显示到页面上的代码
            const translatedText = response.data.trans_result.map(item => item.dst).join('\n');
            console.log(`${text} ==> ${translatedText}`)
            if (typeof node === 'function') {
              node(translatedText);
            }else if(node!=null){
                insertTranslation(node, translatedText); // 插入翻译结果到页面
            }
          }
        } else {
          console.error('翻译失败:', response.error);
        }
    });
  } catch (error) {
    console.error('翻译API调用失败:', error);
    return '翻译失败: ' + error.message;
  }
}

// 简化的翻译测试函数
function testTranslation() {
  console.log('开始翻译测试');
  const testText = 'Hello world';
  console.log('待翻译文本:', testText);
  translateText(testText,null)
}

// 页面加载完成后执行测试
window.addEventListener('load', function() {
  console.log('页面加载完成,准备执行翻译');
  // testTranslation();

  // 添加双击事件监听
  document.addEventListener('dblclick', function(event) {
    let target = event.target;
    // debugger

    // 检查是否是翻译结果元素
    if (target.classList.contains('translation-result')) {
      // 如果点击的是翻译结果,隐藏它
      // console.log(`隐藏当前元素`)
      if(target.style.display!='none'){
        target.style.display = 'none';
      }else{
        target.style.display = 'block';
      }
      return;
    }

    // 判断是否已有翻译结果,若有则判断是否显示
    const tmpChildren = target.children;
    if(tmpChildren && tmpChildren?.length==1 && tmpChildren[0]?.classList?.contains('translation-result')){
      // console.log(`已有翻译结果,判断是否显示`)
      if(tmpChildren[0].style.display=='none' || tmpChildren[0].style.display==''){
        // console.log(`翻译结果被隐藏,显示它`)
        tmpChildren[0].style.display = 'block';
      }else{
        // console.log(`翻译结果已显示,隐藏它`)
        tmpChildren[0].style.display = 'none';
      }
      return
    }

    // target = target.parentNode
    console.log(`翻译当前元素`)

    // 如果不是翻译结果元素,尝试获取文本
    let text = '';
    console.log(`target:${JSON.stringify(target)}`)
    if (target.nodeType === Node.TEXT_NODE) {
      text = target.textContent.trim();
    } else {
      // 如果是元素节点,获取其文本内容
      text = target.innerText.trim();
      // 如果文本太短,尝试获取父节点的文本
      if (text.length < 5) {
        // text = target.parentNode.textContent.trim();
        return
      }
    }
    // 检查文本是否有效且需要翻译
    // target = target.parentNode
    if (isEnglishText(text,{allowMixed:true,maxNonEnglish:1})) {
      // 检查是否已经有翻译结果
      const children = target.children;
      if(children && children?.length==1 && children[0]?.classList?.contains('translation-result')){
        console.log(`已有翻译结果且被隐藏,显示它`)
        children[0].style.display = 'block';
        return
      }
      
      // TODO 如果关闭页面初始化翻译,只在点击元素的时候翻译,那这个地方就将翻译的内容放到了target下一个元素了,而不是放到target孩子元素中,这个地方需要修改一下
      translateText(text, target);
    }
  });
});

源代码

完整源代码已上传至GitHub:项目地址

总结

这个项目虽然小巧,但完整地演示了一个 Chrome 扩展的核心开发流程。

对于想要学习 Chrome 扩展开发和处理网络请求的前端伙伴来说,这是一个非常好的练手项目。欢迎大家前往 GitHub 仓库 克隆代码,尝试运行并提出宝贵的建议。如果你觉得有用,别忘了点个 Star ⭐️!

相关推荐
孟健3 小时前
Claude Code 太贵用不起?这个中转站让你省一半钱,还更稳定
ai编程
AI大模型4 小时前
文科生也能逆袭AI?这5个方向0基础也能成功转行!
程序员·llm·agent
掘我的金10 小时前
POML 与 LangChain 集成
llm
掘我的金10 小时前
POML 与 MCP(Model Context Protocol)集成
llm
ahauedu11 小时前
30分钟入门实战速成Cursor IDE(1)
ide·ai编程·cursor
ahauedu13 小时前
30分钟入门实战速成Cursor IDE(2)
ide·ai编程·cursor
n123523513 小时前
Chrome 插件开发实战:从入门到精通
前端·chrome·microsoft
曼森13 小时前
终极指南:批量自动化处理.gz压缩文件内的中文编码乱码问题
运维·chrome·自动化
i小杨14 小时前
Mac 开发环境与配置操作速查表
前端·chrome