[记录] 无源码给特定应用添加翻译V2 | 祭夜の咖啡馆
  • 欢迎光临,这个博客颜色有点多

[记录] 无源码给特定应用添加翻译V2

JavaScript msojocs 4天前 22次浏览 已收录 0个评论 扫描二维码
文章目录[隐藏]

前言

维护过程中,发现旧版的维护太麻烦了,于是想着重构一下,换个更好的方法来。

前置版本:

[记录] 无源码给特定应用添加翻译

文章目录[隐藏] 前言 困难点 解决 前言 维护的两个项目都刚好有翻译需求。 bilibili-linux - […]

方案

核心还是按照旧版的,创建一个MutationObserver对body进行监视。

对于处理逻辑做了调整,改为自实现一个筛选器;这个筛选器默认取DOM树的所有叶子节点(TextNode),然后在此基础上添加一些过滤条件来忽略一些DOM节点(比如用户创建的数据)。

另外,对于title这类属性的处理,也是把它们当作一个叶子节点(Node)处理;这样就不用像之前一样,翻译是还判断有没有title

这样做下来可以简化为:

  1. 筛选所有有效节点
  2. 对节点的textContent进行直接翻译
  3. 直接翻译失败,尝试正则匹配翻译

代码

获取叶子节点

  const getSingleNode = (node: HTMLElement | ShadowRoot) => {
    // element get
    const eles: (HTMLElement | Node)[] = [node];
    const result = [];
    while (eles.length > 0) {
      const ele = eles.pop();
      if (!ele) continue;
      if (ele.hasChildNodes()) {
        for (let i = 0; i < ele.childNodes.length; i++) {
          const child = ele.childNodes[i];
          // 忽略image
          if (child.nodeName === "IMG") continue;
          // 忽略path
          if (child.nodeName === "path") continue;
          // 忽略svg
          if (child.nodeName === "svg") continue;
          // 忽略br
          if (child.nodeName === "BR") continue;
          // 忽略source
          if (child.nodeName === "SOURCE") continue;
          // 忽略rect
          if (child.nodeName === "rect") continue;
          // 忽略circle
          if (child.nodeName === "circle") continue;
          // 忽略script
          if (child.nodeName === "SCRIPT") continue;
          if (
            child.nodeType === Node.ELEMENT_NODE && child instanceof HTMLElement &&
            (child.className.includes("bili-video-card__image") || // 视频
              child.className.includes("bui-progress-val") || // 百分比
              child.className.includes("bpx-player-ctrl-time-seek") ||
              child.className.includes("bpx-player-dm-mask-wrap") ||
              child.className.includes("bili-bangumi-card__image") ||
              child.className.includes("bili-live-card") ||
              child.className.includes("custom-setting") ||
              child.className.includes("dynamic_live--ups") ||
              child.className.includes("dynamic_card_live_rcmd--mask") ||
              child.className.includes("dynamic_rich_text--content") ||
              child.className.includes("dynamic_card_archive--mask") ||
              child.className.includes("dynamic_card_module_author--info--name") ||
              child.className.includes("dynamic_card_module_forward_author") ||
              child.className.includes("desc-info desc-v2") ||
              child.className.includes("home_live--users-wrap") ||
              child.className.includes("im-li-info") || // 消息
              child.className.includes("picture-ad-card") || // 广告
              (child.className.includes("up_list--item--title") && child.textContent !== '全部动态') ||
              child.className.includes("up-name ") ||
              child.className.includes("video-title") ||
              child.className === "info"
            )
          )
            continue;
          if (
            child.textContent && 
            (
              /^\d+:\d+$/.test(child.textContent)
              || /^\d+\.\d+\.\d+$/.test(child.textContent)
              || /^\d+ \/ \d+$/.test(child.textContent)
              || /^(\d+)$/.test(child.textContent)
            )
          ) continue
          eles.push(child);
          if (child instanceof HTMLElement) {
            const title = child.attributes.getNamedItem("title");
            if (title && title.textContent && title.textContent.length > 0) {
              result.push(title);
            }
            const placeholder = child.attributes.getNamedItem("placeholder");
            if (placeholder && placeholder.textContent && placeholder.textContent.length > 0) {
              result.push(placeholder);
            }
          }
        }
        continue;
      }
      // 单元素节点
      if (!ele.textContent || ele.textContent.length === 0) continue;
  
      if (!isNaN(Number(ele.textContent))) continue
      
      result.push(ele);
    }
    return result;
  };

单节点翻译逻辑

currentDict 包含 simple(直接字符串匹配) 和 regexp(正则匹配) 两种翻译规则:

export const enSimple: Record<string, string> = {
  '确定': 'Confirm',
  '取消': 'Cancel',
}
export const enReg: Record<string, string> = {
  "(\\d+)小时前$": "{0} hours ago",
}
  const translate = (node: HTMLElement | Node) => {
    if (!node.textContent) return false
    // log.info('translate:', node.textContent);
    if (!currentDict) return false
    const key = node.textContent.trim()
    const langText = currentDict.simple[key]
    if (!langText) {
      for (const [reg, text] of Object.entries(currentDict.regexp)) {
        const regExp = new RegExp(reg, 'g')
        if (regExp.test(node.textContent)) {
          node.textContent = node.textContent.replace(regExp, (_ss, ...args) => {
            let t = text
            for (let i = 0; i < args.length; i++) {
              t = t.replace(`{${i}}`, args[i])
            }
            // log.info('reg translation:', t)
            return t
          })
          // log.info('reg trnslation result:', node.textContent)
          return true
        }
      }
      return false
    }
    // 使用replace,因为trim会把换行空格移除掉
    node.textContent = node.textContent.replace(key, langText)
    return true
  }

切换部分

  const switchLanguage = (newLang: string) => {
    if (newLang === lang) return
    document.body.setAttribute('lang', newLang)
    log.info('switchLanguage', newLang)
    currentDict = trnaslationData[newLang]
    lang = newLang
    for (const [node, keyword] of node2keyword.entries()) {
      const r = translate(node)
      if (!r) {
        node.textContent = keyword
      }
    }
  }
  const targetDocument = parent === window ? document : parent.document
  targetDocument.addEventListener('changeLanguage', (e: CustomEventInit<string>) => {
    if (!e.detail) return
    switchLanguage(e.detail)
  })
 
  const observer = new MutationObserver((mutations) => {
    // log.info('[MutationObserver]: mutations', mutations);
    mutations.forEach((mutation) => {
      if (mutation.type === 'childList') {
        const node = mutation.target
        if (node.nodeType === Node.ELEMENT_NODE && node instanceof HTMLElement) {
          const list = getSingleNode(node)
          // log.info('list:', list)
          for (const item of list) {
            if (!item.textContent) continue
            if (!node2keyword.has(item)) {
              node2keyword.set(item, item.textContent);
            } else if (containsFullChinese(item.textContent) && node2keyword.get(item) !== item.textContent) {
              node2keyword.set(item, item.textContent);
            }
            translate(item as HTMLElement);
          }
        } else if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && node instanceof ShadowRoot) {
          const list = getSingleNode(node)
          // log.info('list:', list)
          for (const item of list) {
            if (!item.textContent) continue
            if (!node2keyword.has(item)) {
              node2keyword.set(item, item.textContent);
            } else if (containsFullChinese(item.textContent) && node2keyword.get(item) !== item.textContent) {
              node2keyword.set(item, item.textContent);
            }
            translate(item as HTMLElement);
          }
          // 设置part,使css生效
          for (let i=0; i < node.childNodes.length; i++) {
            const child = node.childNodes[i]
            if (!child) continue
            if (child.nodeType === Node.ELEMENT_NODE && child instanceof HTMLElement && child.id) {
              child.setAttribute('part', child.id)
            }
          }
          // 每层都设置part导出
          node.host?.setAttribute('exportparts', 'options')
        } else if (node.nodeType === Node.TEXT_NODE) {
          translate(node as Node);
        }
      } else if (mutation.type === 'attributes') {
        // if (
        //   mutation.attributeName !== 'class'
        //   && mutation.attributeName !== 'style'
        // )
        // 处理属性变化
        // translate(mutation.target as Node);
      }
    });
  });
  if (!document.body) return
  observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: false,
  });
  {
    const shadow = Element.prototype.attachShadow
    Element.prototype.attachShadow = function (...args) {
      const shadowRoot = shadow.apply(this, args)
      // log.info('[attachShadow]', this, shadowRoot)
      // log.info('[attachShadow] child length:', shadowRoot.childNodes.length)
      observer.observe(shadowRoot, {
        childList: true,
        subtree: true,
        attributes: false,
      })
      return shadowRoot
    }
  }

祭夜の咖啡馆 , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:[记录] 无源码给特定应用添加翻译V2
喜欢 (0)
[1690127128@qq.com]
分享 (0)
发表我的评论
取消评论
OwO表情
贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址