Greasy Fork is available in English.
在网页中的日语外来语上方标注英文原词,且基于原作者Arnie97旧代码修复部分性能问题和bug问题
// ==UserScript==
// @name 片假名终结者(2026修复版)
// @description 在网页中的日语外来语上方标注英文原词,且基于原作者Arnie97旧代码修复部分性能问题和bug问题
// @author Arnie97 (fixed by Marina)
// @license MIT
// @match *://*/*
// @exclude *://*.bilibili.com/video/*
// @grant GM.xmlHttpRequest
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect translate.googleapis.com
// @version 2026.04.24
// @name:ja-JP カタカナターミネーター(2026fixed)
// @name:zh-CN 片假名终结者(2026修复版)
// @name:en Katakana Terminator (2026 fixed)
// @description:zh-CN 在网页中的日语外来语上方标注英文原词
// @description:ja-JP ウェブページ上の外来語(カタカナ語)の上に英単語の原語を表示します。
// @description:en Annotate Japanese gairaigo with their English originals on web pages.
// @namespace http://greasyfork.icu/users/1594580
// ==/UserScript==
(function() {
'use strict';
// ---------- 常量 ----------
const KATAKANA_RE = /[\u30A1-\u30FA\u30FD-\u30FF][\u3099\u309A\u30A1-\u30FF]*[\u3099\u309A\u30A1-\u30FA\u30FC-\u30FF]|[\uFF66-\uFF6F\uFF71-\uFF9D][\uFF65-\uFF9F]*[\uFF66-\uFF9F]/g;
const EXCLUDE_TAGS = { ruby:1, script:1, select:1, textarea:1, input:1 };
// ---------- 状态 ----------
const pendingNodes = []; // 待扫描的节点队列
const phraseToRTs = new Map(); // 片假名 -> [rt元素]
const translationCache = new Map(); // 片假名 -> 英文
let pendingPhrases = new Set(); // 等待翻译的短语
let inflightPhrases = new Set(); // 正在请求中的短语
const processedTextNodes = new WeakSet(); // 已处理过的文本节点,防止重复扫描
let processScheduled = false;
// ---------- 样式 ----------
GM_addStyle("rt.katakana-terminator-rt::before { content: attr(data-rt); }");
// ---------- 工具 ----------
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ---------- 文本扫描 ----------
function scanNode(node) {
if (!node || !document.body.contains(node)) return;
if (node.nodeType === Node.TEXT_NODE) {
processTextNode(node);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.dataset?.ktGenerated === 'true') return; // 跳过脚本生成的 ruby
const tag = node.tagName.toLowerCase();
if (tag in EXCLUDE_TAGS || node.isContentEditable) return;
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
{ acceptNode: textNode => {
let parent = textNode.parentNode;
while (parent && parent !== node) {
if (parent.dataset?.ktGenerated === 'true') return NodeFilter.FILTER_REJECT;
const t = parent.tagName.toLowerCase();
if (t in EXCLUDE_TAGS || parent.isContentEditable) return NodeFilter.FILTER_REJECT;
parent = parent.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
} }
);
let textNode;
while ((textNode = walker.nextNode())) {
processTextNode(textNode);
}
}
function processTextNode(node) {
if (processedTextNodes.has(node)) return; // 已处理过,直接跳过
processedTextNodes.add(node);
// ★ 重置正则状态,防止跨节点污染
KATAKANA_RE.lastIndex = 0;
let current = node;
while (current) {
const match = KATAKANA_RE.exec(current.nodeValue);
if (!match) break;
current = insertRuby(current, match);
}
}
// ★ 修复点:按照原版算法,直接修改 after 节点的 textContent,切割掉片假名
function insertRuby(textNode, match) {
const katakana = match[0];
// 分割文本
const after = textNode.splitText(match.index); // after 包含从匹配位置开始的全部内容
const remainingText = after.nodeValue.substring(katakana.length);
after.nodeValue = remainingText; // ★ 关键:删除前面的片假名
// 创建 ruby 并标记为脚本生成
const ruby = document.createElement('ruby');
ruby.dataset.ktGenerated = 'true';
ruby.appendChild(document.createTextNode(katakana));
const rt = document.createElement('rt');
rt.classList.add('katakana-terminator-rt');
ruby.appendChild(rt);
// 将 ruby 插入到 after 之前
textNode.parentNode.insertBefore(ruby, after);
// 收集 rt 到待翻译列表
if (!phraseToRTs.has(katakana)) phraseToRTs.set(katakana, []);
phraseToRTs.get(katakana).push(rt);
// 排入翻译(智能去重)
if (!translationCache.has(katakana) && !inflightPhrases.has(katakana)) {
pendingPhrases.add(katakana);
}
return after; // 返回已缩短的 after 节点继续扫描
}
// ---------- 队列管理 ----------
function flushQueue() {
if (pendingNodes.length === 0) {
processScheduled = false;
return;
}
// 清空当前队列,防止新增内容干扰(同时保持原子)
const nodesToProcess = pendingNodes.splice(0, pendingNodes.length);
nodesToProcess.forEach(scanNode);
// 处理完队列后触发翻译请求
if (pendingPhrases.size > 0) {
scheduleTranslation();
}
// 若在处理期间又有新节点推入,继续处理
if (pendingNodes.length > 0) {
setTimeout(flushQueue, 10);
} else {
processScheduled = false;
}
}
function enqueueNode(node) {
pendingNodes.push(node);
if (!processScheduled) {
processScheduled = true;
setTimeout(flushQueue, 10); // 异步启动,避免阻塞当前任务
}
}
// ---------- 翻译 API ----------
function scheduleTranslation() {
// 简单的防抖:直接发送当前积累的短语
const phrases = Array.from(pendingPhrases);
pendingPhrases.clear();
if (phrases.length === 0) return;
phrases.forEach(p => inflightPhrases.add(p));
batchTranslate(phrases)
.finally(() => phrases.forEach(p => inflightPhrases.delete(p)));
}
async function batchTranslate(phrases) {
const CHUNK = 100;
for (let i = 0; i < phrases.length; i += CHUNK) {
const chunk = phrases.slice(i, i + CHUNK);
await translateChunk(chunk);
if (i + CHUNK < phrases.length) await sleep(200);
}
}
function translateChunk(phrases) {
return new Promise(resolve => {
const joined = phrases.join('\n');
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&sl=ja&tl=en&q=${encodeURIComponent(joined)}`;
const requester = typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : GM.xmlHttpRequest;
requester({
method: 'GET', url,
onload: resp => {
try {
const data = JSON.parse(resp.responseText.replace(/'/g, '\u2019'));
(data?.[0] || []).forEach(item => {
const original = (item[1] || '').trim();
const translated = (item[0] || '').trim();
if (original && translated) {
translationCache.set(original, translated);
const rtList = phraseToRTs.get(original);
if (rtList) {
rtList.forEach(rt => rt.dataset.rt = translated);
phraseToRTs.delete(original);
}
}
});
} catch (e) {
console.error('Katakana Terminator: 翻译解析失败', e);
}
resolve();
},
onerror: () => resolve()
});
});
}
// ---------- MutationObserver (始终连接) ----------
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
// 跳过脚本自己生成的 ruby 及其内部节点
if (node.nodeType === Node.ELEMENT_NODE && node.dataset?.ktGenerated === 'true') continue;
enqueueNode(node);
}
}
}
});
// ---------- 启动 ----------
function init() {
enqueueNode(document.body);
observer.observe(document.body, { childList: true, subtree: true });
}
// 兼容 Greasemonkey 4
if (typeof GM_xmlhttpRequest === 'undefined' && typeof GM === 'object' && GM.xmlHttpRequest) {
GM_xmlhttpRequest = GM.xmlHttpRequest;
}
if (typeof GM_addStyle === 'undefined') {
GM_addStyle = css => {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
};
}
init();
})();