【开源推荐】双击即译!我用 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 ⭐️!

相关推荐
牛奶19 小时前
2026年大模型怎么选?前端人实用对比
前端·人工智能·ai编程
牛奶19 小时前
前端人为什么要学AI?
前端·人工智能·ai编程
EdisonZhou1 天前
MAF快速入门(18)Agent Skill 快速开始
llm·aigc·agent
KEEN的创享空间1 天前
AI编程从0到1之10X提效(Vibe Coding 氛围式编码 )09篇
openai·ai编程
AlienZHOU1 天前
为 AI Agent 编写高质量 Skill:Claude 官方指南
agent·ai编程·claude
恋猫de小郭1 天前
移动端开发稳了?AI 目前还无法取代客户端开发,小红书的论文告诉你数据
前端·flutter·ai编程
KaneLogger1 天前
【翻译】打造 Agent Skills 的最佳实践
agent·ai编程·claude
王小酱1 天前
Everything Claude Code 文档
openai·ai编程·aiops
雮尘1 天前
如何在非 Claude IDE (TARE、 Cursor、Antigravity 等)下使用 Agent Skills
前端·agent·ai编程
会写代码的柯基犬1 天前
DeepSeek vs Kimi vs Qwen —— AI 生成俄罗斯方块代码效果横评
人工智能·llm