Greasy Fork

Greasy Fork is available in English.

片假名终结者(2026修复版)

在网页中的日语外来语上方标注英文原词,且基于原作者Arnie97旧代码修复部分性能问题和bug问题

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();