feat: add shadowroot injector

This commit is contained in:
Gabe
2025-10-28 00:07:44 +08:00
parent 66d39da80a
commit 9d8f3f4211
15 changed files with 129 additions and 275 deletions

View File

@@ -32,7 +32,8 @@ const extWebpack = (config, env) => {
options: paths.appSrc + "/options.js",
background: paths.appSrc + "/background.js",
content: paths.appSrc + "/content.js",
injector: paths.appSrc + "/injector.js",
"injector-subtitle": paths.appSrc + "/injector-subtitle.js",
"injector-shadowroot": paths.appSrc + "/injector-shadowroot.js",
};
config.output.filename = "[name].js";

View File

@@ -17,7 +17,8 @@
}
],
"web_accessible_resources": [
"injector.js"
"injector-subtitle.js",
"injector-shadowroot.js"
],
"commands": {
"_execute_browser_action": {

View File

@@ -19,8 +19,12 @@
],
"web_accessible_resources": [
{
"resources": ["injector.js"],
"resources": ["injector-subtitle.js"],
"matches": ["https://www.youtube.com/*"]
},
{
"resources": ["injector-shadowroot.js"],
"matches": ["<all_urls>"]
}
],
"commands": {

View File

@@ -23,7 +23,8 @@
}
],
"web_accessible_resources": [
"injector.js"
"injector-subtitle.js",
"injector-shadowroot.js"
],
"commands": {
"_execute_browser_action": {

View File

@@ -0,0 +1,3 @@
import { shadowRootInjector } from "./injectors/shadowroot";
shadowRootInjector();

3
src/injector-subtitle.js Normal file
View File

@@ -0,0 +1,3 @@
import { XMLHttpRequestInjector } from "./injectors/xmlhttp";
XMLHttpRequestInjector();

View File

@@ -1,3 +0,0 @@
import { XMLHttpRequestInjector } from "./subtitle/XMLHttpRequestInjector";
XMLHttpRequestInjector();

27
src/injectors/index.js Normal file
View File

@@ -0,0 +1,27 @@
import { browser } from "../libs/browser";
import { isExt } from "../libs/client";
import { injectExternalJs, injectInlineJs } from "../libs/injector";
import { shadowRootInjector } from "./shadowroot";
import { XMLHttpRequestInjector } from "./xmlhttp";
export const INJECTOR = {
subtitle: "injector-subtitle.js",
shadowroot: "injector-shadowroot.js",
};
const injectorMap = {
[INJECTOR.subtitle]: XMLHttpRequestInjector,
[INJECTOR.shadowroot]: shadowRootInjector,
};
export function injectJs(name, id = "kiss-translator-inject-js") {
const injector = injectorMap[name];
if (!injector) return;
if (isExt) {
const src = browser.runtime.getURL(name);
injectExternalJs(src, id);
} else {
injectInlineJs(`(${injector})()`, id);
}
}

View File

@@ -0,0 +1,12 @@
export const shadowRootInjector = () => {
try {
const orig = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (...args) {
const root = orig.apply(this, args);
window.postMessage({ type: "KISS_SHADOW_ROOT_CREATED" }, "*");
return root;
};
} catch (err) {
console.log("shadowRootInjector", err);
}
};

23
src/injectors/xmlhttp.js Normal file
View File

@@ -0,0 +1,23 @@
export const XMLHttpRequestInjector = () => {
try {
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args) {
const url = args[1];
if (typeof url === "string" && url.includes("timedtext")) {
this.addEventListener("load", function () {
window.postMessage(
{
type: "KISS_XHR_DATA_YOUTUBE",
url: this.responseURL,
response: this.responseText,
},
window.location.origin
);
});
}
return originalOpen.apply(this, args);
};
} catch (err) {
console.log("XMLHttpRequestInjector", err);
}
};

View File

@@ -1,56 +0,0 @@
import { kissLog } from "./log";
/**
* @class ShadowRootMonitor
* @description 通过覆写 Element.prototype.attachShadow 来监控页面上所有新创建的 Shadow DOM
*/
export default class ShadowRootMonitor {
/**
* @param {function(ShadowRoot): void} callback - 当一个新的 shadowRoot 被创建时调用的回调函数。
*/
constructor(callback) {
if (typeof callback !== "function") {
throw new Error("Callback must be a function.");
}
this.callback = callback;
this.isMonitoring = false;
this.originalAttachShadow = Element.prototype.attachShadow;
}
/**
* 开始监控 shadowRoot 的创建。
*/
start() {
if (this.isMonitoring) {
return;
}
const monitorInstance = this;
Element.prototype.attachShadow = function (...args) {
const shadowRoot = monitorInstance.originalAttachShadow.apply(this, args);
if (shadowRoot) {
try {
monitorInstance.callback(shadowRoot);
} catch (error) {
kissLog("Error in ShadowRootMonitor callback", error);
}
}
return shadowRoot;
};
this.isMonitoring = true;
}
/**
* 停止监控,并恢复原始的 attachShadow 方法。
*/
stop() {
if (!this.isMonitoring) {
return;
}
Element.prototype.attachShadow = this.originalAttachShadow;
this.isMonitoring = false;
}
}

View File

@@ -17,7 +17,6 @@ import {
OPT_SPLIT_PARAGRAPH_TEXTLENGTH,
} from "../config";
import interpreter from "./interpreter";
import ShadowRootMonitor from "./shadowRootMonitor";
import { clearFetchPool } from "./pool";
import { debounce, scheduleIdle, genEventName, truncateWords } from "./utils";
import { apiTranslate } from "../apis";
@@ -31,6 +30,7 @@ import { createLoadingSVG } from "./svg";
import { shortcutRegister } from "./shortcut";
import { tryDetectLang } from "./detect";
import { trustedTypesHelper } from "./trustedTypes";
import { injectJs, INJECTOR } from "../injectors";
/**
* @class Translator
@@ -278,6 +278,7 @@ export class Translator {
#rule; // 规则
#isInitialized = false; // 初始化状态
#isJsInjected = false; // 注入用户JS
#isShadowRootJsInjected = false; //
#mouseHoverEnabled = false; // 鼠标悬停翻译
#enabled = false; // 全局默认状态
#runId = 0; // 用于中止过期的异步请求
@@ -305,11 +306,13 @@ export class Translator {
#hoveredNode = null; // 存储当前悬停的可翻译节点
#boundMouseMoveHandler; // 鼠标事件
#boundKeyDownHandler; // 键盘事件
#windowMessageHandler = null;
#debouncedFindShadowRoot = null;
#io; // IntersectionObserver
#mo; // MutationObserver
#dmm; // DebounceMouseMover
#srm; // ShadowRootMonitor
#rescanQueue = new Set(); // “脏容器”队列
#isQueueProcessing = false; // 队列处理状态标志
@@ -368,12 +371,12 @@ export class Translator {
this.#io = this.#createIntersectionObserver();
this.#mo = this.#createMutationObserver();
this.#dmm = this.#createDebounceMouseMover();
this.#srm = this.#createShadowRootMonitor();
// 监控shadowroot
if (this.#rule.hasShadowroot === "true") {
this.#srm.start();
}
this.#windowMessageHandler = this.#handleWindowMessage.bind(this);
this.#debouncedFindShadowRoot = debounce(
this.#findAndObserveShadowRoot.bind(this),
300
);
// 鼠标悬停翻译
if (this.#setting.mouseHoverSetting.useMouseHover) {
@@ -410,15 +413,41 @@ export class Translator {
this.#startObserveRoot(root);
});
// 查找现有的所有shadowroot
if (this.#rule.hasShadowroot === "true") {
try {
this.#findAllShadowRoots().forEach((shadowRoot) => {
this.#startObserveShadowRoot(shadowRoot);
});
} catch (err) {
kissLog("findAllShadowRoots", err);
}
this.#attachShadowRootListener();
this.#findAndObserveShadowRoot();
}
}
#handleWindowMessage(event) {
if (event.data?.type === "KISS_SHADOW_ROOT_CREATED") {
this.#debouncedFindShadowRoot();
}
}
#attachShadowRootListener() {
if (!this.#isShadowRootJsInjected) {
const id = "kiss-translator-inject-shadowroot-js";
injectJs(INJECTOR.shadowroot, id);
this.#isShadowRootJsInjected = true;
}
window.addEventListener("message", this.#windowMessageHandler);
}
#removeShadowRootListener() {
window.removeEventListener("message", this.#windowMessageHandler);
}
// 查找现有的所有shadowroot
#findAndObserveShadowRoot() {
try {
this.#findAllShadowRoots().forEach((shadowRoot) => {
this.#startObserveShadowRoot(shadowRoot);
});
} catch (err) {
kissLog("findAllShadowRoots", err);
}
}
@@ -611,13 +640,6 @@ export class Translator {
}, 100);
}
// 创建shadowroot的回调
#createShadowRootMonitor() {
return new ShadowRootMonitor((shadowRoot) => {
this.#startObserveShadowRoot(shadowRoot);
});
}
// 跟踪鼠标下的可翻译节点
#handleMouseMove(event) {
let targetNode = event.composedPath()[0];
@@ -1527,6 +1549,8 @@ export class Translator {
// 停止监听,重置参数
#resetOptions() {
this.#removeShadowRootListener();
this.#io.disconnect();
this.#mo.disconnect();
this.#viewNodes.clear();
@@ -1697,7 +1721,6 @@ export class Translator {
stop() {
this.disable();
this.#resetOptions();
this.#srm.stop();
this.#disableMouseHover();
this.#removeInjector();
this.#isInitialized = false;

View File

@@ -1,158 +0,0 @@
/**
* 修复程序类型
*/
export const FIXER_NONE = "-";
export const FIXER_BR = "br";
export const FIXER_BN = "bn";
export const FIXER_BR_DIV = "brToDiv";
export const FIXER_BN_DIV = "bnToDiv";
export const FIXER_ALL = [
FIXER_NONE,
FIXER_BR,
FIXER_BN,
FIXER_BR_DIV,
FIXER_BN_DIV,
];
/**
* 修复过的标记
*/
const fixedSign = "kiss-fixed";
/**
* 采用 `br` 换行网站的修复函数
* 目标是将 `br` 替换成 `p`
* @param {*} node
* @returns
*/
function brFixer(node, tag = "p") {
if (node.hasAttribute(fixedSign)) {
return;
}
node.setAttribute(fixedSign, "true");
const gapTags = ["BR", "WBR"];
const newlineTags = [
"DIV",
"UL",
"OL",
"LI",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"P",
"HR",
"PRE",
"TABLE",
"BLOCKQUOTE",
];
let html = "";
node.childNodes.forEach(function (child, index) {
if (index === 0) {
html += `<${tag} class="kiss-p">`;
}
if (gapTags.indexOf(child.nodeName) !== -1) {
html += `</${tag}><${tag} class="kiss-p">`;
} else if (newlineTags.indexOf(child.nodeName) !== -1) {
html += `</${tag}>${child.outerHTML}<${tag} class="kiss-p">`;
} else if (child.outerHTML) {
html += child.outerHTML;
} else if (child.textContent) {
html += child.textContent;
}
if (index === node.childNodes.length - 1) {
html += `</${tag}>`;
}
});
node.innerHTML = html;
}
function brDivFixer(node) {
return brFixer(node, "div");
}
/**
* 目标是将 `\n` 替换成 `p`
* @param {*} node
* @returns
*/
function bnFixer(node, tag = "p") {
if (node.hasAttribute(fixedSign)) {
return;
}
node.setAttribute(fixedSign, "true");
node.innerHTML = node.innerHTML
.split("\n")
.map((item) => `<${tag} class="kiss-p">${item || "&nbsp;"}</${tag}>`)
.join("");
}
function bnDivFixer(node) {
return bnFixer(node, "div");
}
/**
* 查找、监听节点,并执行修复函数
* @param {*} selector
* @param {*} fixer
* @param {*} rootSelector
*/
function run(selector, fixer, rootSelector) {
const mutaObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(function (addNode) {
if (addNode && addNode.querySelectorAll) {
addNode.querySelectorAll(selector).forEach(function (node) {
fixer(node);
});
}
});
});
});
let rootNodes = [document];
if (rootSelector) {
rootNodes = document.querySelectorAll(rootSelector);
}
rootNodes.forEach(function (rootNode) {
rootNode.querySelectorAll(selector).forEach(function (node) {
fixer(node);
});
mutaObserver.observe(rootNode, {
childList: true,
subtree: true,
});
});
}
/**
* 修复程序映射
*/
const fixerMap = {
[FIXER_BR]: brFixer,
[FIXER_BN]: bnFixer,
[FIXER_BR_DIV]: brDivFixer,
[FIXER_BN_DIV]: bnDivFixer,
};
/**
* 执行fixer
* @param {*} param0
*/
export function runFixer(selector, fixer = "-", rootSelector) {
try {
if (Object.keys(fixerMap).includes(fixer)) {
run(selector, fixerMap[fixer], rootSelector);
}
} catch (err) {
console.error(`[kiss-webfix run]: ${err.message}`);
}
}

View File

@@ -1,19 +0,0 @@
export const XMLHttpRequestInjector = () => {
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args) {
const url = args[1];
if (typeof url === "string" && url.includes("timedtext")) {
this.addEventListener("load", function () {
window.postMessage(
{
type: "KISS_XHR_DATA_YOUTUBE",
url: this.responseURL,
response: this.responseText,
},
window.location.origin
);
});
}
return originalOpen.apply(this, args);
};
};

View File

@@ -1,18 +1,15 @@
import { YouTubeInitializer } from "./YouTubeCaptionProvider.js";
import { browser } from "../libs/browser.js";
import { isMatch } from "../libs/utils.js";
import { DEFAULT_API_SETTING } from "../config/api.js";
import { DEFAULT_SUBTITLE_SETTING } from "../config/setting.js";
import { injectExternalJs } from "../libs/injector.js";
import { logger } from "../libs/log.js";
import { XMLHttpRequestInjector } from "./XMLHttpRequestInjector.js";
import { injectInlineJs } from "../libs/injector.js";
import { injectJs, INJECTOR } from "../injectors/index.js";
const providers = [
{ pattern: "https://www.youtube.com", start: YouTubeInitializer },
];
export function runSubtitle({ href, setting, isUserscript }) {
export function runSubtitle({ href, setting }) {
try {
const subtitleSetting = setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING;
if (!subtitleSetting.enabled) {
@@ -21,13 +18,8 @@ export function runSubtitle({ href, setting, isUserscript }) {
const provider = providers.find((item) => isMatch(href, item.pattern));
if (provider) {
const id = "kiss-translator-xmlHttp-injector";
if (isUserscript) {
injectInlineJs(`(${XMLHttpRequestInjector})()`, id);
} else {
const src = browser.runtime.getURL("injector.js");
injectExternalJs(src, id);
}
const id = "kiss-translator-inject-subtitle-js";
injectJs(INJECTOR.subtitle, id);
const apiSetting =
setting.transApis.find(