jpmarumaru 羅馬拼音歌詞

讓 marumaru 日文歌詞能以羅馬拼音形式出現

// ==UserScript==
// @name                jpmarumaru 羅馬拼音歌詞
// @namespace           Anong0u0
// @version             1.1.5
// @description         讓 marumaru 日文歌詞能以羅馬拼音形式出現
// @author              Anong0u0
// @match               *://www.marumaru-x.com/japanese-song/play-*
// @icon                https://www.google.com/s2/favicons?sz=64&domain=marumaru-x.com
// @grant               GM_xmlhttpRequest
// @grant               GM_registerMenuCommand
// @grant               GM.setValue
// @grant               GM_getValue
// @grant               GM_deleteValue
// @grant               GM_listValues
// @connect             raw.githubusercontent.com
// @license             MIT
// ==/UserScript==

const delay = (ms = 0) => new Promise((r)=>{setTimeout(r, ms)});
(async ()=>
{
    while(typeof $player === 'undefined') await delay(10);
    while($player?.lyrics?.length === 0) await delay(10);

    const use_split = GM_getValue("use_split", true), songID = document.URL.split("/").pop();

    String.prototype.replaceRegex = function(k, v) { return this.replace(new RegExp(k, "gm"), v) }
    const ROMAJI_KANA_ARR = "xtsu=っ|ッ,shi=し|シ,chi=ち|チ,tsu=つ|ツ,ka=か|カ,ki=き|キ,ku=く|ク,ke=け|ケ,ko=こ|コ,ga=が|ガ,gi=ぎ|ギ,gu=ぐ|グ,ge=げ|ゲ,go=ご|ゴ,sa=さ|サ,su=す|ス,se=せ|セ,so=そ|ソ,za=ざ|ザ,ji=じ|ジ,zu=ず|ズ,ze=ぜ|ゼ,zo=ぞ|ゾ,ta=た|タ,te=て|テ,to=と|ト,da=だ|ダ,dzi=ぢ|ヂ,dzu=づ|ヅ,de=で|デ,do=ど|ド,na=な|ナ,ni=に|ニ,nu=ぬ|ヌ,ne=ね|ネ,no=の|ノ,ha=は|ハ,hi=ひ|ヒ,fu=ふ|フ,he=へ|ヘ,ho=ほ|ホ,pa=ぱ|パ,pi=ぴ|ピ,pu=ぷ|プ,pe=ぺ|ペ,po=ぽ|ポ,ba=ば|バ,bi=び|ビ,bu=ぶ|ブ,be=べ|ベ,bo=ぼ|ボ,ma=ま|マ,mi=み|ミ,mu=む|ム,me=め|メ,mo=も|モ,ra=ら|ラ,ri=り|リ,ru=る|ル,re=れ|レ,ro=ろ|ロ,wa=わ|ワ,wi=ゐ|ヰ,we=ゑ|ヱ,wo=を|ヲ,va=ゔぁ|ヴァ,vi=ゔぃ|ヴィ,ve=ゔぇ|ヴェ,vo=ゔぉ|ヴォ,vu=ゔ|ヴ,n=ん|ン,xa=ぁ|ァ,xi=ぃ|ィ,xu=ぅ|ゥ,xe=ぇ|ェ,xo=ぉ|ォ,xya=ゃ|ャ,xyu=ゅ|ュ,xyo=ょ|ョ,ya=や|ヤ,yu=ゆ|ユ,yo=よ|ヨ,a=あ|ア,i=い|イ,u=う|ウ,e=え|エ,o=お|オ,h=っ|ッ,-=ー".split(",").map((e) => e.split("="))
    const KANA_REPLACE_ARR = "fux([aieo])=f$1,ixy=y,(s|c)hy=$1h,dzu=zu,(dz|j)y?=j,xtsu([rtpsdfghjkbm])=$1$1,xtsuc=tc,x=~".split(",").map((e) => e.split("="))
    String.prototype.toRomaji = function()
    {
        let kana = this
        kana = kana.replace(/([んン])([あいうえおやゆよアイウエオヤユヨ])/gm, "$1'$2")

        ROMAJI_KANA_ARR.forEach((arr) => { kana = kana.replaceRegex(arr[1], arr[0]) })

        KANA_REPLACE_ARR.forEach((arr) => { kana = kana.replaceRegex(arr[0], arr[1]) })
        return kana
    }
    Node.prototype.getKanaLyrics = function()
    {
        const temp = this.cloneNode(true)
        temp.querySelectorAll("rb").forEach(e => e.remove())
        return temp.innerText
    }
    String.prototype.getKanaLyrics = function()
    {
        const div = document.createElement("div")
        div.innerHTML = this
        return div.getKanaLyrics()
    }
    Node.prototype.getNormalLyrics = function()
    {
        const temp = this.cloneNode(true)
        temp.querySelectorAll(":is(rt,rtc,.ateji)").forEach(e => e.remove())
        return temp.innerText
    }
    String.prototype.getNormalLyrics = function()
    {
        const div = document.createElement("div")
        div.innerHTML = this
        return div.getNormalLyrics()
    }

    const css = document.createElement("style")
    css.innerHTML = `
#caption-style-2 .mr-lyrics.font-jp1[lang=ja] {display: block; padding-top:10px}
#caption-style-2 .mr-lyrics-display-2 {padding-top: 5px}`
    document.body.append(css)

    let lyricsSplited = GM_getValue(songID, {})

    const lyricsStore = {},
          lyrics1 = document.querySelector("#caption-style-1 .mr-lyrics-1"),
          lyrics2 = document.querySelector("#caption-style-1 .mr-lyrics-2"),
          romaji1 = [document.createElement("div"), document.createElement("div")],
          romaji2 = [document.createElement("div"), document.createElement("div")],
          lyricsNormal2Kana = {},
          lyricsKana2Ateji = {}
    $player.lyrics.forEach((e)=>
    {
        lyricsKana2Ateji[e.kana] = e.ateji.getKanaLyrics()
        lyricsNormal2Kana[e.normal] = e.kana
    })
    lyrics1.insertAdjacentElement("afterend", romaji1[0])
    lyrics2.insertAdjacentElement("afterend", romaji2[0])
    document.querySelector("#caption-style-2 .mr-lyrics-1").insertAdjacentElement("afterend", romaji1[1])
    document.querySelector("#caption-style-2 .mr-lyrics-2").insertAdjacentElement("afterend", romaji2[1])

    new MutationObserver((e) =>
    {
        e.forEach((ele) =>
        {
            if (ele.type == "attributes")
            {
                romaji1.forEach((e)=>{e.style.color = lyrics1.style.color})
                romaji2.forEach((e)=>{e.style.color = lyrics2.style.color})
            }
            else if (ele.addedNodes.length != 0 && ele.target.className.match(/mr-lyrics-\d/))
            {
                ele = ele.target
                if (lyricsStore[ele.className] == ele.innerText) return;
                lyricsStore[ele.className] = ele.innerText

                let ly = ele.getNormalLyrics()
                if (ly in lyricsNormal2Kana) ly = lyricsNormal2Kana[ly]
                const rmj = (use_split ? lyricsSplited[ly] : lyricsKana2Ateji[ly]).toRomaji();

                if (ele.className.includes("mr-lyrics-1")) romaji1.forEach((e)=>{e.innerText = rmj})
                else romaji2.forEach((e)=>{e.innerText = rmj})
            }
        })
    }).observe(document.querySelector("div#caption-style-1"),
    {
        subtree: true,
        childList: true,
        attributes: true
    })



    GM_registerMenuCommand(`${use_split?"關閉":"開啟"}日文分詞`, ()=>
    {
        GM.setValue("use_split", !use_split).then(async ()=>
        {
            if(use_split && confirm(`已關閉日文分詞,是否刪除分詞字典與分詞快取?`))
            {
                indexedDB.deleteDatabase("naist-jdic");
                GM_listValues().forEach((e)=>GM_deleteValue(e))
                await GM.setValue("use_split", false)
            }
            location.reload()
        })
    })
    if (!use_split) return;

    const tip = document.createElement("span")
    tip.style = "position: absolute;left: 8rem;"
    document.querySelector("#mr-toolbar > .d-sm-block").insertAdjacentElement("afterend", tip)
    document.querySelector("#mr-toolbar").style["align-items"] = "center"


    const requests = ({ method, url, data = null, headers = {}, type = "stream" }) => {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: method,
                url: url,
                headers: headers,
                responseType: type,
                overrideMimeType: "text/javascript",
                data: data,
                onload: resolve
            });
        });
    };

    const UNKNOWN_DEFINITION = [
        { name: 'DEFAULT', invoke: false, regexp: '' }, // 0
        { name: 'SPACE', invoke: true, regexp: '\\s+' }, // 1
        { name: 'KANJI', invoke: false, regexp: '[\u{2e80}-\u{2fdf}々〇〻\u{3400}-\u{4dbf}\u{4e00}-\u{9fff}\u{f900}-\u{faff}\u{20000}-\u{2ffff}]{1,2}' }, // 2
        { name: 'SYMBOL', invoke: true, regexp: '[!-/:-@[-`{-~¡-¿À-ȶḀ-ỹ!-/:-@[-`{-・¢-\u{ffef}\u{2000}-\u{206f}₠-⅏←-⥿⨀-\u{2bff}\u{3000}-\u{303f}㈀-㏿︰-﹫]+' }, // 3
        { name: 'NUMERIC', invoke: true, regexp: '[0-90-9⁰-\u{209f}⅐-\u{218f}]+' }, // 4
        { name: 'ALPHA', invoke: true, regexp: '[A-Za-zA-Za-z]+' }, // 5
        { name: 'HIRAGANA', invoke: false, regexp: '[ぁ-ゟー]{1,4}' }, // 6
        { name: 'KATAKANA', invoke: true, regexp: '[ァ-ヿㇰ-ㇿヲ-ン゙゚]+' }, // 7
        { name: 'KANJINUMERIC', invoke: true, regexp: '[〇一二三四五六七八九十百千万億兆京]+' }, // 8
        { name: 'GREEK', invoke: true, regexp: '[ʹ-ϻ]+' }, // 9
        { name: 'CYRILLIC', invoke: true, regexp: '[Ѐ-ӹԀ-ԏ]+' }, // 10
    ];

    const Halfwidth2Fullwidth = (str) => {
        if (!str) return '';
        let result = [];
        for (let s of str) {
            let p = s.codePointAt(0);
            if (0x0020 < p && p < 0x007f) {
                result.push(String.fromCharCode(p + 65248));
            } else {
                result.push(s);
            }
        }
        return result.join('');
    }
    const BOS = {
        word: '\x02',
        id: 0,
        cost: 0,
        start: 0,
        end: 1,
    };
    const EOS = {
        word: '\x03',
        id: 0,
        cost: 0,
    };
    class Path extends Array {
        constructor(length) {
            super();
            this.length = length || 0;
            this.cost = 0;
        }
        format() {
            const cost = this.cost;
            const newPath = Path.from(this.slice(1, this.length - 1));
            newPath.cost = cost;
            return newPath;
        }
        static from(arraylike) {
            const length = arraylike.length;
            const path = new Path(length)
            for (let i = 0; i < length; i++) path[i] = arraylike[i];
            return path;
        }
    }
    const mtx = [];
    class Lattice {
        constructor(input) {
            this.input = [...input];
        }
        lookup(unkDic) {
            let chars = this.input;
            const CHAR_LENGTH = chars.length;
            return new Promise((resolve, reject) => {
                indexedDB.open("naist-jdic").onsuccess = e => {
                    let db = e.target.result, dic;
                    try {
                        dic = db.transaction(['dictionary'], 'readonly').objectStore('dictionary').index('index');
                    } catch (e) {
                        db.close();
                        indexedDB.deleteDatabase("naist-jdic");
                        reject("找不到字典");
                    }
                    let targets = [], promises = [];
                    for (let i = 0; i < CHAR_LENGTH; i++) {
                        for (let j = i; j < CHAR_LENGTH; j++) {
                            promises.push(new Promise((resolve, reject) => {
                                let targetKey = chars.slice(i, j + 1).join('');
                                let req = dic.openCursor(Halfwidth2Fullwidth(targetKey));
                                req.onsuccess = e => {
                                    let cursor = e.target.result;
                                    if (cursor) {
                                        cursor.value.start = i + 1;
                                        cursor.value.end = j + 2;
                                        targets.push(cursor.value);
                                        cursor.continue();
                                    } else {
                                        // Skip DEFAULT (k=0)
                                        for (let k = 1; k < unkDic.length; k++) {
                                            if (new RegExp('^' + unkDic[k].regexp + '$', 'u').test(targetKey)) {
                                                targets.push({
                                                    word: targetKey,
                                                    id: unkDic[k].id,
                                                    cost: unkDic[k].cost,
                                                    pos: unkDic[k].pos,
                                                    start: i + 1,
                                                    end: j + 2,
                                                    note: k && '未知語'
                                                    || targetKey === '\n' && '改行'
                                                    || targetKey === '\t' && 'タブ'
                                                    || '空白'
                                                });
                                            }
                                        }
                                        resolve();
                                    }
                                };
                                req.onerror = e => reject(e);
                            }));
                        }
                    }
                    Promise.all(promises).then(() => {
                        targets.push(Object.assign({}, BOS), Object.assign({}, EOS, {
                            start: CHAR_LENGTH + 1,
                            end: CHAR_LENGTH + 2,
                        }));
                        this.words = targets.sort((a, b) => {
                            return a.start - b.start || a.end - b.end;
                        });
                        resolve(this.words);
                    }, reject).then(() => db.close());
                };
            });
        }
        tokenize() {
            let words = this.words;
            let len = words.length;
            let mCosts = new Array(len).fill().map(() => new Array(len));
            return new Promise((resolve, reject) => {
                let promises = [];
                indexedDB.open("naist-jdic").onsuccess = e => {
                    let db = e.target.result;
                    let matrix = db.transaction(['matrix'], 'readonly').objectStore('matrix');
                    for (let y = 0; y < len; y++) {
                        let rightId = words[y].id;
                        promises.push(new Promise((resolve, reject) => {
                            if (mtx[rightId]) {
                                resolve();
                            } else {
                                let req = matrix.get(rightId);
                                req.onsuccess = e => {
                                    let result = e.target.result;
                                    if (result) {
                                        mtx[rightId] = e.target.result.left;
                                        resolve();
                                    } else {
                                        reject("找不到matrix");
                                    }
                                };
                                req.onerror = e => reject(e);
                            }
                        }).then(() => {
                            for (let x = 0; x < len; x++) {
                                let leftId = words[x].id;
                                mCosts[y][x] = words[x].end === words[y].start ? mtx[rightId][leftId] : Infinity;
                            }
                        }));
                    }
                    Promise.all(promises).then(() => {
                        let vertex = new Array(len).fill().map(() => ({
                            cost: Infinity,
                            next: -1,
                            visited: false,
                        }));
                        vertex[len - 1] = {
                            cost: words[len - 1].cost, // 0
                            next: len,
                            visited: false,
                        };
                        while (true) {
                            let min = Infinity;
                            for (let i = 0; i < len; i++) {
                                if (!vertex[i].visited && vertex[i].cost < min) min = vertex[i].cost;
                            }
                            if (min === Infinity) break;
                            for (let y = 0; y < len; y++) {
                                if (vertex[y].cost === min) {
                                    for (let x = 0; x < len; x++) {
                                        let sum = mCosts[y][x] + words[y].cost + min;
                                        if (sum < vertex[x].cost) {
                                            vertex[x].cost = sum;
                                            vertex[x].next = y;
                                        }
                                    }
                                    vertex[y].visited = true;
                                }
                            }
                        }
                        let index = 0, path = new Path();
                        path.cost = vertex[index].cost;
                        while (index < len) {
                            let word = words[index];
                            if (word) {
                                path.push(word);
                                index = vertex[index].next;

                            } else {
                                reject("找不到word");
                                break;
                            }
                        }
                        resolve(path.format());
                    }).catch(e => reject(e));
                };
            });
        }
    }
    const Katakana2Hiragana = (str) => {
        if (!str) return '';
        let result = [];
        for (let s of str) {
            let p = s.codePointAt(0);
            if (0x30a0 < p && p < 0x30f5) {
                result.push(String.fromCharCode(p - 96));
            } else {
                result.push(s);
            }
        }
        return result.join('');
    }

    const buf = await requests({ method: "get", url: "https://raw.githubusercontent.com/Anong0u0/MeCabJS/master/naist-jdic.unknown.bin", type: "arraybuffer" })
    const array = new Uint16Array(buf.response);
    const unkDicAll = new Array(array.length / 4);
    for (let i = 0; i < unkDicAll.length; i++) {
        unkDicAll[i] = Object.assign({}, UNKNOWN_DEFINITION[array[i * 4 + 0]], {
            id: array[i * 4 + 1],
            cost: array[i * 4 + 2],
            pos: array[i * 4 + 3],
        });
    }
    const unkDicNormal = unkDicAll.filter(v => v.invoke);
    delete buf
    delete array

    String.prototype.mecabSplit = function() {
        return new Promise(r => {
            const lattice = new Lattice(this)
            new Promise((resolve, reject) => {
                lattice.lookup(unkDicNormal || [])
                    .then(() => lattice.tokenize(), e => reject(e))
                    .then(v => resolve(v), () => lattice.lookup(unkDicAll || []))
                    .then(() => lattice.tokenize())
                    .then(v => resolve(v))
                    .catch(e => reject(e));
            }).then(v => {
                const s = Katakana2Hiragana(v.map((e) => e.pron || e.orth || e.word).join(" ")).replace(/ 、 /g, "、").replace(/ -/g, "-")
                r(s)
            }).catch(()=>{r(String(this))})
        })
    };

    const splitLyrics = async () =>
    {
        const total = Object.keys(lyricsKana2Ateji).length
        lyricsSplited = GM_getValue(songID, {})
        let now = 0
        for(const kana in lyricsKana2Ateji)
        {
            now++
            const ateji = lyricsKana2Ateji[kana]
            if(ateji in lyricsSplited) continue
            lyricsSplited[kana] = await ateji.mecabSplit()
            tip.innerText = `分詞中(${now}/${total})...`
            GM.setValue(songID, lyricsSplited)
        }
        tip.remove()
    }
    if(GM_getValue("naist-jdic_init")) splitLyrics();

    if (!GM_getValue("naist-jdic_init", false)) {
        tip.innerText = `下載字典中...`

        const workerContent = `
    self.onmessage = e => {
    importScripts(e.data.gunzip);
    Promise.all([
        new Promise((resolve) => {
            fetch(e.data.bin).then(res => res.arrayBuffer()).then(buffer => {
                let u8array = new Zlib.Gunzip(new Uint8Array(buffer)).decompress();
                resolve(new Int16Array(u8array.buffer));
            });
        }),
        new Promise((resolve) => {
            fetch(e.data.tsv).then(res => res.arrayBuffer()).then(buffer => {
                let u8array = new Zlib.Gunzip(new Uint8Array(buffer)).decompress();
                resolve(new TextDecoder().decode(u8array).split('\\n'));
            });
        }),
    ]).then(values => {
        const openReq = indexedDB.open("naist-jdic");
        openReq.onupgradeneeded = e => {
            const db = e.target.result;
            db.createObjectStore('matrix', { keyPath: 'right' });
            db.createObjectStore('dictionary', { autoIncrement: true }).createIndex('index', 'word');
        };
        openReq.onsuccess = e => {
            const db = e.target.result;
            const tx = db.transaction(['matrix', 'dictionary'], 'readwrite');
            tx.oncomplete = e => {
                self.postMessage({ state: 'done' });
                self.close();
            };
            tx.onerror = e => {
                throw tx.error;
            };
            tx.onabort = tx.onerror;

            const matrix = tx.objectStore('matrix');
            const bin = values[0];
            const SizeX = bin[0] >>> 0, SizeY = bin[1] >>> 0;

            const dictionary = tx.objectStore('dictionary');
            const words = values[1];
            const SizeWords = words.length;

            const SumSize = SizeY + SizeWords,
                one = (SumSize*.01).toFixed(0);
            for (let i = 0; i < SizeY; i++) {
                const start = 2 + i * SizeY;
                const req = matrix.put({
                    right: i,
                    left: [...bin.subarray(start, start + SizeX)],
                });
                req.onsuccess = e => {if(i%one==0) self.postMessage({ state: 'processing', total: SumSize, now: i })}
            }

            for (let j = 0; j < SizeWords; j++) {
                const c = words[j].split('\\t');
                let token = {
                    word: c[0],
                    id: Number(c[1]),
                    cost: Number(c[2]),
                    pos: Number(c[3]),
                };
                if (c[4]) token.cjg = [ c[4], c[5] ];
                if (c[6]) token.base = c[6];
                if (c[7]) token.orth = c[7];
                if (c[8]) token.pron = c[8];
                const req = dictionary.put(token);
                req.onsuccess = e => {if((SizeY+j)%one==0) self.postMessage({ state: 'processing', total: SumSize, now: (SizeY+j) })}
            }
        };
        openReq.onerror = e => {throw 'データベースに接続できません';};
    }).catch(e => {
        self.postMessage({ state: 'error' });
        self.close();
    });
    }
    `
        const worker = new Worker(URL.createObjectURL(new Blob([workerContent], { type: 'text/javascript' })));

        const bin = URL.createObjectURL((await requests({ method: "get", url: "https://raw.githubusercontent.com/Anong0u0/MeCabJS/master/naist-jdic.matrix.bin.gz", type: "blob" })).response),
            tsv = URL.createObjectURL((await requests({ method: "get", url: "https://raw.githubusercontent.com/Anong0u0/MeCabJS/master/naist-jdic.min.tsv.gz", type: "blob" })).response),
            gunzip = URL.createObjectURL((await requests({ method: "get", url: "https://raw.githubusercontent.com/Anong0u0/MeCabJS/master/gunzip.min.js", type: "blob" })).response)

        let tipMsg = null;
        worker.onmessage = e => {
            e = e.data
            if(e.state == "processing")
            {
                const t = `整理字典中(${(e.now/e.total*100).toFixed(0)}%)...`
                if(tipMsg == t) return
                tipMsg = t
                tip.innerText = t
                return;
            }
            worker.terminate();
            if (e.state === 'done') {
                GM.setValue("naist-jdic_init", true)
                splitLyrics();
            }
            else if (e.state === 'error')
            {
                tip.innerText = `字典整理出錯`
                alert("羅馬拼音腳本執行錯誤");
            }
            delete bin
            delete tsv
            delete gunzip
        };
        worker.onerror = (e) => {
            console.log(e);
            tip.innerText = `字典整理出錯`
            alert("羅馬拼音腳本執行錯誤");
            worker.terminate()
        }
        worker.postMessage({ bin, tsv, gunzip });
    }
})()