JPDB Immersion Kit Examples

Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.

当前为 2024-09-08 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         JPDB Immersion Kit Examples
// @version      1.1
// @description  Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.
// @author       awoo
// @namespace    jpdb-immersion-kit-examples
// @match        https://jpdb.io/review*
// @match        https://jpdb.io/vocabulary/*
// @match        https://jpdb.io/kanji/*
// @grant        GM_addElement
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        IMAGE_WIDTH: '400px',
        ENABLE_EXAMPLE_TRANSLATION: true,
        SENTENCE_FONT_SIZE: '120%',
        TRANSLATION_FONT_SIZE: '85%',
        COLORED_SENTENCE_TEXT: true,
        AUTO_PLAY_SOUND: true,
        SOUND_VOLUME: 0.8,
        NUM_PRELOADS: 1
    };

    const state = {
        currentExampleIndex: 0,
        examples: [],
        apiDataFetched: false,
        vocab: '',
        embedAboveSubsectionMeanings: false,
        preloadedIndices: new Set()
    };

    function getImmersionKitData(vocab, callback) {
        const url = `https://api.immersionkit.com/look_up_dictionary?keyword=${encodeURIComponent(vocab)}&sort=shortness`;
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            onload: function(response) {
                if (response.status === 200) {
                    try {
                        const jsonData = JSON.parse(response.responseText);
                        if (jsonData.data && jsonData.data[0] && jsonData.data[0].examples) {
                            state.examples = jsonData.data[0].examples;
                            state.apiDataFetched = true;
                            state.currentExampleIndex = parseInt(getStoredData(vocab)) || 0;
                        }
                    } catch (e) {
                        console.error('Error parsing JSON response:', e);
                    }
                }
                callback();
            },
            onerror: function(error) {
                console.error('Error fetching data:', error);
                callback();
            }
        });
    }

    function getStoredData(key) {
        return localStorage.getItem(key);
    }

    function storeData(key, value) {
        localStorage.setItem(key, value);
    }

    function exportFavorites() {
        const favorites = {};
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            favorites[key] = localStorage.getItem(key);
        }
        const blob = new Blob([JSON.stringify(favorites, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'favorites.json';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    function importFavorites(event) {
        const file = event.target.files[0];
        if (!file) return;

        const reader = new FileReader();
        reader.onload = function(e) {
            try {
                const favorites = JSON.parse(e.target.result);
                for (const key in favorites) {
                    localStorage.setItem(key, favorites[key]);
                }
                alert('Favorites imported successfully!');
            } catch (error) {
                alert('Error importing favorites:', error);
            }
        };
        reader.readAsText(file);
    }

    function parseVocabFromAnswer() {
        const elements = document.querySelectorAll('a[href*="/kanji/"], a[href*="/vocabulary/"]');
        for (const element of elements) {
            const href = element.getAttribute('href');
            const text = element.textContent.trim();
            const match = href.match(/\/(kanji|vocabulary)\/(?:\d+\/)?([^\#]*)#/);
            if (match) return match[2].trim();
            if (text) return text.trim();
        }
        return '';
    }

    function parseVocabFromReview() {
        const kindElement = document.querySelector('.kind');
        if (!kindElement) return '';

        const kindText = kindElement.textContent.trim();
        if (kindText !== 'Kanji' && kindText !== 'Vocabulary') return '';

        if (kindText === 'Vocabulary') {
            const plainElement = document.querySelector('.plain');
            if (!plainElement) return '';

            let vocabulary = plainElement.textContent.trim();
            const nestedVocabularyElement = plainElement.querySelector('div:not([style])');
            if (nestedVocabularyElement) {
                vocabulary = nestedVocabularyElement.textContent.trim();
            }
            const specificVocabularyElement = plainElement.querySelector('div:nth-child(3)');
            if (specificVocabularyElement) {
                vocabulary = specificVocabularyElement.textContent.trim();
            }

            const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
            if (kanjiRegex.test(vocabulary) || vocabulary) {
                return vocabulary;
            }
        } else if (kindText === 'Kanji') {
            const hiddenInput = document.querySelector('input[name="c"]');
            if (!hiddenInput) return '';

            const vocab = hiddenInput.value.split(',')[1];
            const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
            if (kanjiRegex.test(vocab)) {
                return vocab;
            }
        }

        return '';
    }

    function parseVocabFromVocabulary() {
        const url = window.location.href;
        const match = url.match(/https:\/\/jpdb\.io\/vocabulary\/(\d+)\/([^\#]*)#a/);
        if (match) {
            let vocab = match[2];
            state.embedAboveSubsectionMeanings = true;
            vocab = vocab.split('/')[0];
            return decodeURIComponent(vocab);
        }
        return '';
    }

    function parseVocabFromKanji() {
        const url = window.location.href;
        const match = url.match(/https:\/\/jpdb\.io\/kanji\/([^#]*)#a/);
        if (match) {
            return decodeURIComponent(match[1]);
        }
        return '';
    }

    function highlightVocab(sentence, vocab) {
        if (!CONFIG.COLORED_SENTENCE_TEXT) return sentence;
        return vocab.split('').reduce((acc, char) => {
            const regex = new RegExp(char, 'g');
            return acc.replace(regex, `<span style="color: var(--outline-input-color);">${char}</span>`);
        }, sentence);
    }

    function createIconLink(iconClass, onClick, marginLeft = '0') {
        const link = document.createElement('a');
        link.href = '#';
        link.style.border = '0';
        link.style.display = 'inline-flex';
        link.style.verticalAlign = 'middle';
        link.style.marginLeft = marginLeft;

        const icon = document.createElement('i');
        icon.className = iconClass;
        icon.style.fontSize = '1.4rem';
        icon.style.opacity = '1.0';
        icon.style.verticalAlign = 'baseline';
        icon.style.color = '#3d81ff';

        link.appendChild(icon);
        link.addEventListener('click', onClick);
        return link;
    }

    function createStarLink() {
        const link = document.createElement('a');
        link.href = '#';
        link.style.border = '0';
        link.style.display = 'inline-flex';
        link.style.verticalAlign = 'middle';

        const starIcon = document.createElement('span');
        starIcon.textContent = state.currentExampleIndex === parseInt(getStoredData(state.vocab)) ? '★' : '☆';
        starIcon.style.fontSize = '1.4rem';
        starIcon.style.marginLeft = '0.5rem';
        starIcon.style.color = '3D8DFF';
        starIcon.style.verticalAlign = 'middle';
        starIcon.style.position = 'relative';
        starIcon.style.top = '-2px';

        link.appendChild(starIcon);
        link.addEventListener('click', (event) => {
            event.preventDefault();
            const favoriteIndex = parseInt(getStoredData(state.vocab));
            storeData(state.vocab, favoriteIndex === state.currentExampleIndex ? null : state.currentExampleIndex);
            embedImageAndPlayAudio();
        });
        return link;
    }

    function createExportImportButtons() {
        const exportButton = document.createElement('button');
        exportButton.textContent = 'Export Favorites';
        exportButton.style.marginRight = '10px';
        exportButton.addEventListener('click', exportFavorites);

        const importButton = document.createElement('button');
        importButton.textContent = 'Import Favorites';
        importButton.addEventListener('click', () => {
            const fileInput = document.createElement('input');
            fileInput.type = 'file';
            fileInput.accept = 'application/json';
            fileInput.addEventListener('change', importFavorites);
            fileInput.click();
        });

        const buttonContainer = document.createElement('div');
        buttonContainer.style.textAlign = 'center';
        buttonContainer.style.marginTop = '10px';
        buttonContainer.append(exportButton, importButton);

        document.body.appendChild(buttonContainer);
    }

function playAudio(soundUrl) {
    if (soundUrl) {
        // Fetch the audio file as a blob and create a blob URL
        GM_xmlhttpRequest({
            method: 'GET',
            url: soundUrl,
            responseType: 'blob',
            onload: function(response) {
                const blobUrl = URL.createObjectURL(response.response);
                const audioElement = GM_addElement('audio', { src: blobUrl, autoplay: true });
                audioElement.volume = CONFIG.SOUND_VOLUME; // Adjust volume as needed
            },
            onerror: function(error) {
                console.error('Error fetching audio:', error);
            }
        });
    }
}

    function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) {
        const example = state.examples[state.currentExampleIndex] || {};
        const imageUrl = example.image_url || null;
        const soundUrl = example.sound_url || null;
        const sentence = example.sentence || null;

        const resultVocabularySection = document.querySelector('.result.vocabulary');
        const hboxWrapSection = document.querySelector('.hbox.wrap');
        const subsectionMeanings = document.querySelector('.subsection-meanings');
        const subsectionLabels = document.querySelectorAll('h6.subsection-label');

        // Remove any existing embed before creating a new one
        const existingEmbed = document.getElementById('immersion-kit-embed');
        if (existingEmbed) {
            existingEmbed.remove();
        }

        if (resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3) {
            const wrapperDiv = document.createElement('div');
            wrapperDiv.id = 'image-wrapper';
            wrapperDiv.style.textAlign = 'center';
            wrapperDiv.style.padding = '5px 0';

            const textDiv = document.createElement('div');
            textDiv.style.marginBottom = '5px';
            textDiv.style.lineHeight = '1.4rem';

            const contentText = document.createElement('span');
            contentText.textContent = 'Immersion Kit';
            contentText.style.color = 'var(--subsection-label-color)';
            contentText.style.fontSize = '85%';
            contentText.style.marginRight = '0.5rem';
            contentText.style.verticalAlign = 'middle';

            const speakerLink = createIconLink('ti ti-volume', (event) => {
                event.preventDefault();
                playAudio(soundUrl);
            }, '0.5rem');

            const starLink = createStarLink();

            textDiv.append(contentText, speakerLink, starLink);
            wrapperDiv.appendChild(textDiv);

            if (imageUrl) {
                const imageElement = GM_addElement(wrapperDiv, 'img', {
                    src: imageUrl,
                    alt: 'Embedded Image',
                    style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
                });

                if (imageElement) {
                    imageElement.addEventListener('click', () => {
                        speakerLink.click();
                    });
                }

                if (sentence) {
                    const sentenceText = document.createElement('div');
                    sentenceText.innerHTML = highlightVocab(sentence, vocab);
                    sentenceText.style.marginTop = '10px';
                    sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
                    sentenceText.style.color = 'lightgray';
                    wrapperDiv.appendChild(sentenceText);

                    if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && example.translation) {
                        const translationText = document.createElement('div');
                        translationText.innerHTML = replaceSpecialCharacters(example.translation);
                        translationText.style.marginTop = '5px';
                        translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
                        translationText.style.color = 'var(--subsection-label-color)';
                        wrapperDiv.appendChild(translationText);
                    }
                } else {
                    const noneText = document.createElement('div');
                    noneText.textContent = 'None';
                    noneText.style.marginTop = '10px';
                    noneText.style.fontSize = '85%';
                    noneText.style.color = 'var(--subsection-label-color)';
                    wrapperDiv.appendChild(noneText);
                }
            }

            // Create a fixed-width container for arrows and image
            const navigationDiv = document.createElement('div');
            navigationDiv.id = 'immersion-kit-embed';
            navigationDiv.style.display = 'flex';
            navigationDiv.style.justifyContent = 'center';
            navigationDiv.style.alignItems = 'center';
            navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
            navigationDiv.style.margin = '0 auto';

            const leftArrow = document.createElement('button');
            leftArrow.textContent = '<';
            leftArrow.style.marginRight = '10px';
            leftArrow.disabled = state.currentExampleIndex === 0;
            leftArrow.addEventListener('click', () => {
                if (state.currentExampleIndex > 0) {
                    state.currentExampleIndex--;
                    embedImageAndPlayAudio();
                    preloadImages();
                }
            });

            const rightArrow = document.createElement('button');
            rightArrow.textContent = '>';
            rightArrow.style.marginLeft = '10px';
            rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
            rightArrow.addEventListener('click', () => {
                if (state.currentExampleIndex < state.examples.length - 1) {
                    state.currentExampleIndex++;
                    embedImageAndPlayAudio();
                    preloadImages();
                }
            });

            navigationDiv.append(leftArrow, wrapperDiv, rightArrow);

            if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
                subsectionMeanings.parentNode.insertBefore(navigationDiv, subsectionMeanings);
            } else if (resultVocabularySection) {
                resultVocabularySection.parentNode.insertBefore(navigationDiv, resultVocabularySection);
            } else if (hboxWrapSection) {
                hboxWrapSection.parentNode.insertBefore(navigationDiv, hboxWrapSection);
            } else if (subsectionLabels.length >= 4) {
                subsectionLabels[3].parentNode.insertBefore(navigationDiv, subsectionLabels[3]);
            }
        }

        if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) {
            playAudio(soundUrl);
        }
    }

    function embedImageAndPlayAudio() {
        const existingNavigationDiv = document.getElementById('immersion-kit-embed');
        if (existingNavigationDiv) existingNavigationDiv.remove();

        const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;

        if (state.vocab && !state.apiDataFetched) {
            getImmersionKitData(state.vocab, () => {
                renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
                preloadImages();
            });
        } else {
            renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
            preloadImages();
        }
    }

    function replaceSpecialCharacters(text) {
        return text.replace(/<br>/g, '\n').replace(/&quot;/g, '"').replace(/\n/g, '<br>');
    }

    function preloadImages() {
        const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });

        for (let i = Math.max(0, state.currentExampleIndex - CONFIG.NUM_PRELOADS); i <= Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUM_PRELOADS); i++) {
            if (!state.preloadedIndices.has(i)) {
                const example = state.examples[i];
                if (example.image_url) {
                    GM_addElement(preloadDiv, 'img', { src: example.image_url });
                    state.preloadedIndices.add(i);
                }
            }
        }
    }

    function onUrlChange() {
        state.embedAboveSubsectionMeanings = false;
        if (window.location.href.includes('/vocabulary/')) {
            state.vocab = parseVocabFromVocabulary();
        } else if (window.location.href.includes('c=')) {
            state.vocab = parseVocabFromAnswer();
        } else if (window.location.href.includes('/kanji/')) {
            state.vocab = parseVocabFromKanji();
        } else {
            state.vocab = parseVocabFromReview();
        }

        const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
        const shouldAutoPlaySound = !reviewUrlPattern.test(window.location.href);

        if (state.vocab) {
            embedImageAndPlayAudio();
        }
    }

    const observer = new MutationObserver(() => {
        if (window.location.href !== observer.lastUrl) {
            observer.lastUrl = window.location.href;
            onUrlChange();
        }
    });

    observer.lastUrl = window.location.href;
    observer.observe(document, { subtree: true, childList: true });

    onUrlChange();
    createExportImportButtons();
})();