Katakana To Pronunciation

Convert gairaigo (Japanese loan words) back to Pronunciation

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Katakana To Pronunciation
// @description Convert gairaigo (Japanese loan words) back to Pronunciation
// @author      一生的等待
// @license     MIT
// @copyright   2023, Katakana Terminator Contributors (https://github.com/mrdhr/katakana-terminator/graphs/contributors)
// @namespace   https://github.com/mrdhr
// @homepageURL https://github.com/mrdhr/katakana-terminator
// @supportURL  https://greasyfork.org/zh-CN/scripts/473556/feedback
// @icon        https://upload.wikimedia.org/wikipedia/commons/2/28/Ja-Ruby.png
// @match       *://*/*
// @exclude     *://*.bilibili.com/video/*
// @require     https://unpkg.com/[email protected]/dist/jquery.min.js
// @require     https://unpkg.com/[email protected]/dist/sweetalert2.all.min.js
// @grant       GM.xmlHttpRequest
// @grant       GM_xmlhttpRequest
// @grant       GM_addStyle
// @grant       GM_registerMenuCommand
// @grant       GM_setValue
// @grant       GM_getValue
// @connect     translate.google.cn
// @connect     translate.google.com
// @connect     translate.googleapis.com
// @connect     trans.mrdvh.com
// @version     2023.11.15
// @name:ja-JP  カタカナターミネーター
// @name:zh-CN  片假名终结者
// @description:zh-CN 在网页中的日语外来语上方标注读音,修改自"https://github.com/Arnie97/katakana-terminator"
// ==/UserScript==
// define some shorthands
const _ = document;

let queue = {};  // {"カタカナ": [rtNodeA, rtNodeB]}
let cachedTranslations = {};  // {"ターミネーター": "Terminator"}
let newNodes = [_.body];

// Recursively traverse the given node and its descendants (Depth-first search)
const scanTextNodes = (node) => {
    // The node could have been detached from the DOM tree
    if (!node.parentNode || !_.body.contains(node)) {
        return;
    }

    // Ignore text boxes and echoes
    const excludeTags = {ruby: true, script: true, select: true, textarea: true};

    switch (node.nodeType) {
        case Node.ELEMENT_NODE:
            if (node.tagName.toLowerCase() in excludeTags || node.isContentEditable) {
                return;
            }
            return node.childNodes.forEach(scanTextNodes);

        case Node.TEXT_NODE:
            while ((node = addRuby(node))) ;
    }
};

// Recursively add ruby tags to text nodes
const addRuby = (node) => {
    const katakana = /[\u30A1-\u30FA\u30FD-\u30FF][\u3099\u309A\u30A1-\u30FF]*[\u3099\u309A\u30A1-\u30FA\u30FC-\u30FF]|[\uFF66-\uFF6F\uFF71-\uFF9D][\uFF65-\uFF9F]*[\uFF66-\uFF9F]/;
    let match;
    if (!node.textContent || !(match = katakana.exec(node.textContent))) {
        return false;
    }
    const ruby = _.createElement('ruby');

    // 添加背景色和透明度
    const backgroundColor = GM_getValue('backgroundColor') || '#ffff00';
    const transparentBackground = GM_getValue('transparentBackground') || false;
    ruby.style.backgroundColor = backgroundColor;

    if (transparentBackground) {
        ruby.style.background = `rgba(255, 255, 0, 0)`;
    }

    ruby.appendChild(_.createTextNode(match[0]));
    const rt = _.createElement('rt');
    rt.classList.add('katakana-terminator-rt');
    ruby.appendChild(rt);

    // Append the ruby title node to the pending-translation queue
    queue[match[0]] = queue[match[0]] || [];
    queue[match[0]].push(rt);

    // <span>[startカナmiddleテストend]</span> =>カナmiddleテストend]</span> =
    // <span>start<ruby>カナ<rt data-rt="Kana"></rt></ruby>[middleテストend]</span>
    const after = node.splitText(match.index);
    node.parentNode.insertBefore(ruby, after);
    after.textContent = after.textContent.substring(match[0].length);
    return after;
};

// Split word list into chunks to limit the length of API requests
const translateTextNodes = () => {
    let apiRequestCount = 0;
    let phraseCount = 0;
    const chunkSize = 200;
    let chunk = [];

    for (const phrase in queue) {
        phraseCount++;
        if (phrase in cachedTranslations) {
            updateRubyByCachedTranslations(phrase);
            continue;
        }

        chunk.push(phrase);
        if (chunk.length >= chunkSize) {
            apiRequestCount++;
            googleTranslate('ja', 'en', chunk);
            chunk = [];
        }
    }

    if (chunk.length) {
        apiRequestCount++;
        googleTranslate('ja', 'en', chunk);
    }

    if (phraseCount) {
        console.debug('Katakana Terminator:', phraseCount, 'phrases translated in', apiRequestCount, 'requests, frame', window.location.href);
    }
};

// {"keyA": 1, "keyB": 2} => "?keyA=1&keyB=2"
const buildQueryString = (params) => {
    return '?' + Object.keys(params).map((k) => {
        return encodeURIComponent(k) + '=' + encodeURIComponent(params[k]);
    }).join('&');
};

// Google Dictionary API, https://github.com/ssut/py-googletrans/issues/268
const googleTranslate = (srcLang, destLang, phrases) => {
    // Prevent duplicate HTTP requests before the request completes
    phrases.forEach((phrase) => {
        cachedTranslations[phrase] = null;
    });

    const joinedText = phrases.join('\n').replace(/\s+$/, '');
    const api = 'https://trans.mrdvh.com/convert';
    const params = {
        text: joinedText
    };

    GM_xmlhttpRequest({
        method: "POST",
        url: api + buildQueryString(params),
        onload: (dom) => {
            try {
                const resp = JSON.parse(dom.responseText.replace("'", '\u2019'));
                resp[0].forEach((item) => {
                    const translated = item[0].replace(/\s+$/, '');
                    const original = item[1].replace(/\s+$/, '');
                    cachedTranslations[original] = translated;
                    updateRubyByCachedTranslations(original);
                });
            } catch (err) {
                console.error('Katakana Terminator: invalid response', dom.responseText, err);
                return;
            }
        },
        onerror: (dom) => {
            console.error('Katakana Terminator: request error', dom.statusText);
        },
    });
};

// Clear the pending-translation queue
const updateRubyByCachedTranslations = (phrase) => {
    if (!cachedTranslations[phrase]) {
        return;
    }
    (queue[phrase] || []).forEach((node) => {
        node.dataset.rt = cachedTranslations[phrase];
    });
    delete queue[phrase];
};

// Watch newly added DOM nodes, and save them for later use
const mutationHandler = (mutationList) => {
    mutationList.forEach((mutationRecord) => {
        mutationRecord.addedNodes.forEach((node) => {
            newNodes.push(node);
        });
    });
};

// 主函数
const main = () => {
    GM_addStyle("rt.katakana-terminator-rt::before { content: attr(data-rt); }");

    if (typeof MutationObserver !== 'undefined') {
        observer = new MutationObserver(mutationHandler);
        observer.observe(_.body, {childList: true, subtree: true});
    }

    const rescanTextNodes = () => {
        // Deplete buffered mutations
        mutationHandler(observer.takeRecords());
        if (!newNodes.length) {
            return;
        }

        console.debug('Katakana Terminator:', newNodes.length, 'new nodes were added, frame', window.location.href);
        newNodes.forEach(scanTextNodes);
        newNodes.length = 0;
        translateTextNodes();
    };

    // Limit the frequency of API requests
    rescanTextNodes();
    setInterval(rescanTextNodes, 500);
    GM_registerMenuCommand('打开设置对话框', showSettingsDialog);
};

// 显示设置对话框
const showSettingsDialog = () => {
    const backgroundColor = GM_getValue('backgroundColor') || '#ffff00';
    const transparentBackground = GM_getValue('transparentBackground') || false;

    // 添加变暗效果
    const overlayDiv = document.createElement('div');
    overlayDiv.style.position = 'fixed';
    overlayDiv.style.top = '0';
    overlayDiv.style.left = '0';
    overlayDiv.style.width = '100%';
    overlayDiv.style.height = '100%';
    overlayDiv.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
    overlayDiv.style.zIndex = '9998';
    document.body.appendChild(overlayDiv);

    const dialogHTML = `
        <div id="katakana-terminator-settings-dialog" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0); width: 250px; height: 150px; padding: 20px; background-color: white; border: 1px solid #ccc; z-index: 9999; text-align: center; border-radius: 10px; animation: fadeIn 0.3s ease-out forwards;">
            <h3 style="margin-bottom: 20px; font-size: 1.5em;">设置</h3>
            <div style="display: flex; align-items: center; justify-content: center;">
                <label for="backgroundColor" style="margin-right: 10px; font-size: 1em;">背景色:</label>
                <input type="color" id="backgroundColor" name="backgroundColor" value="${backgroundColor}" style="margin-right: 10px;">
                <label for="transparentBackground" style="font-size: 1em;">背景透明:</label>
                <input type="checkbox" id="transparentBackground" name="transparentBackground" ${transparentBackground ? 'checked' : ''} style="margin-top: 2px;">
            </div>
            <button id="saveSettings" style="margin-top: 20px; margin-right: 10px; padding: 8px 16px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1em;">保存设置</button>
            <button id="closeSettingsDialog" style="margin-top: 20px; margin-left: 10px; padding: 8px 16px; background-color: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1em;">关闭</button>
        </div>
    `;

    document.body.insertAdjacentHTML('beforeend', dialogHTML);

    // 监听保存设置按钮的点击事件
    document.getElementById('saveSettings').addEventListener('click', () => {
        saveSettingsAndRefresh();
    });

    // 监听关闭设置对话框按钮的点击事件
    document.getElementById('closeSettingsDialog').addEventListener('click', () => {
        hideSettingsDialog();
        document.body.removeChild(overlayDiv); // 移除变暗效果
    });

    // 添加缩放动画的CSS样式
    GM_addStyle(`
        @keyframes fadeIn {
            from {
                transform: translate(-50%, -50%) scale(0);
            }
            to {
                transform: translate(-50%, -50%) scale(1);
            }
        }

        @keyframes fadeOut {
            from {
                transform: translate(-50%, -50%) scale(1);
            }
            to {
                transform: translate(-50%, -50%) scale(0);
            }
        }
    `);
};

// 关闭设置对话框
const hideSettingsDialog = () => {
    const dialog = document.getElementById('katakana-terminator-settings-dialog');
    if (dialog) {
        dialog.style.animation = 'fadeOut 0.3s ease-out forwards';
        setTimeout(() => {
            dialog.remove();
        }, 300); // 等待动画结束后移除对话框
    }
};

// Polyfill for Greasemonkey 4
if (typeof GM_xmlhttpRequest === 'undefined' &&
    typeof GM === 'object' && typeof GM.xmlHttpRequest === 'function') {
    GM_xmlhttpRequest = GM.xmlHttpRequest;
}

if (typeof GM_addStyle === 'undefined') {
    GM_addStyle = function (css) {
        const head = _.getElementsByTagName('head')[0];
        if (!head) {
            return null;
        }

        const style = _.createElement('style');
        style.setAttribute('type', 'text/css');
        style.textContent = css;
        head.appendChild(style);
        return style;
    };
}

// Polyfill for ES5
if (typeof NodeList.prototype.forEach === 'undefined') {
    NodeList.prototype.forEach = function (callback, thisArg) {
        thisArg = thisArg || window;
        for (let i = 0; i < this.length; i++) {
            callback.call(thisArg, this[i], i, this);
        }
    };
}

main();