前言
维护过程中,发现旧版的维护太麻烦了,于是想着重构一下,换个更好的方法来。
前置版本:
[记录] 无源码给特定应用添加翻译
文章目录[隐藏] 前言 困难点 解决 前言 维护的两个项目都刚好有翻译需求。 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
}
}


