前言
维护过程中,发现旧版的维护太麻烦了,于是想着重构一下,换个更好的方法来。
前置版本:
[记录] 无源码给特定应用添加翻译
文章目录[隐藏] 前言 困难点 解决 前言 维护的两个项目都刚好有翻译需求。 bilibili-linux - […]
方案
核心还是按照旧版的,创建一个MutationObserver
对body进行监视。
对于处理逻辑做了调整,改为自实现一个筛选器;这个筛选器默认取DOM树的所有叶子节点(TextNode),然后在此基础上添加一些过滤条件来忽略一些DOM节点(比如用户创建的数据)。
另外,对于title
这类属性的处理,也是把它们当作一个叶子节点(Node)处理;这样就不用像之前一样,翻译是还判断有没有title
。
这样做下来可以简化为:
- 筛选所有有效节点
- 对节点的
textContent
进行直接翻译 - 直接翻译失败,尝试正则匹配翻译
代码
获取叶子节点
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 } }