WaniKani Speech Input

Helps you answer WaniKani prompts using speech recognition

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         WaniKani Speech Input
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  Helps you answer WaniKani prompts using speech recognition
// @author       Anonymous
// @match        https://www.wanikani.com/subjects/*
// @license MIT
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const recognition = new window.webkitSpeechRecognition();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = 'en-US';

    let language = null;
    let quizData = null;
    let currentSubject = null;

    const footer = document.createElement('div');
    footer.style.position = 'fixed';
    footer.style.left = '0';
    footer.style.bottom = '0';
    footer.style.width = '100%';
    footer.style.background = 'rgba(0, 0, 0, 0.5)';
    footer.style.color = 'white';
    footer.style.padding = '10px';
    footer.style.fontSize = '36px';
    footer.style.textAlign = 'center';
    document.body.appendChild(footer);

    const quizObserver = new MutationObserver((mutationsList) => {
        for (let mutation of mutationsList) {
            if (mutation.type === 'childList') {
                checkAll();
            }
        }
    });

    async function checkAll() {
        const questionTypeSpan = document.querySelector('.quiz-input__question-type');
        if (questionTypeSpan) {
            const questionType = questionTypeSpan.textContent.trim();
            const newLanguage = questionType === 'reading' ? 'ja' : 'en';
            if (newLanguage !== language) {
                language = newLanguage;
                console.log('Detected language: ', language);
                recognition.stop(); // stop recognition before changing the language
            }
        }
        const scriptTags = Array.from(document.getElementsByTagName('script'));
        const quizScript = scriptTags.find(tag => tag.getAttribute('type') === 'application/json' && tag.getAttribute('data-quiz-queue-target') === 'subjects');
        if (quizScript) {
            quizData = JSON.parse(quizScript.textContent);
        }

        const characterDiv = document.querySelector('.character-header__characters');
        if (characterDiv && quizData) {
            const characters = characterDiv.textContent.trim();
            currentSubject = quizData.find(subject => subject.characters === characters);
        }
    }

    let lastCallTimestamp = 0;

    // Function to fetch Kanji data from API
    async function fetchKanjiData(kanji) {
        const response = await fetch(`https://kanjiapi.dev/v1/kanji/${kanji}`);
        const data = await response.json();
        return data;
    }

    // Function to split text into individual Kanji
    function splitIntoKanji(text) {
        return Array.from(text).filter(char => (char.charCodeAt(0) >= 0x4E00 && char.charCodeAt(0) <= 0x9FFF));
    }


    async function enterAnswerAndNext(answer) {
        const now = Date.now();
        const minimumDelay = 3000; // Minimum delay between calls in milliseconds

        if (now - lastCallTimestamp < minimumDelay) {
            console.log('Function called too soon, ignoring.');
            return;
        }

        lastCallTimestamp = now;

        const inputBox = document.querySelector('input.quiz-input__input[data-quiz-input-target="input"]');
        inputBox.value = answer;
        console.log(`Answer entered: ${answer}`);

        const submitButton = document.querySelector('button.quiz-input__submit-button[data-quiz-input-target="button"]');
        if (submitButton) {
            submitButton.click();
            console.log('Submit button clicked');

            await new Promise(resolve => {
                const inputContainer = document.querySelector('div[data-quiz-input-target="inputContainer"]');
                let checkCount = 0;

                const intervalId = setInterval(() => {
                    const correctValue = inputContainer.getAttribute('correct');
                    if (correctValue === 'true') {
                        submitButton.click();
                        console.log('Submit button clicked again because answer was correct');
                        clearInterval(intervalId);
                        resolve();
                    } else if (checkCount > 3) { // stop checking after ~2 seconds
                        clearInterval(intervalId);
                        resolve();
                    }

                    checkCount++;
                }, 500);
            });
        } else {
            console.log('Submit button not found');
        }
    }

    function convertKana(input) {
        let hiraganaOutput = '';
        let katakanaOutput = '';

        for (let i = 0; i < input.length; i++) {
            let charCode = input.charCodeAt(i);

            // If the character is Hiragana, convert it to Katakana and vice versa.
            if (charCode >= 0x3041 && charCode <= 0x3096) {
                // Hiragana to Katakana: Add 96 to the charCode.
                katakanaOutput += String.fromCharCode(charCode + 96);
                hiraganaOutput += input[i];
            } else if (charCode >= 0x30A1 && charCode <= 0x30F6) {
                // Katakana to Hiragana: Subtract 96 from the charCode.
                hiraganaOutput += String.fromCharCode(charCode - 96);
                katakanaOutput += input[i];
            } else {
                // If the character is not Kana, just append it.
                hiraganaOutput += input[i];
                katakanaOutput += input[i];
            }
        }

        return [hiraganaOutput, katakanaOutput];
    }

    function enterWrongAnswer(language) {
        const now = Date.now();
        const minimumDelay = 3000; // Minimum delay between calls in milliseconds

        if (now - lastCallTimestamp < minimumDelay) {
            console.log('Function called too soon, ignoring.');
            return;
        }

        lastCallTimestamp = now;

        var answer = "this is a wrong answer";
        if (language === 'ja') {
            answer = "これはだめです"
        }

        const inputBox = document.querySelector('input.quiz-input__input[data-quiz-input-target="input"]');
        inputBox.value = answer;
        console.log(`Answer entered: ${answer}`);

        const submitButton = document.querySelector('button.quiz-input__submit-button[data-quiz-input-target="button"]');
        if (submitButton) {
            submitButton.click();
            console.log('Submit button clicked for wrong answer');
        } else {
            console.log('Submit button not found');
        }


    }

    recognition.onresult = event => {
        for (let i = event.resultIndex; i < event.results.length; i++) {
            if (event.results[i].isFinal) {
                const transcript = event.results[i][0].transcript.trim().toLowerCase();
                footer.textContent = `${transcript}`;
                console.log(`Transcript: ${transcript}`);

                // override to let user mark something wrong manually
                if(transcript === "wrong" || transcript === "ダメ") {
                    enterWrongAnswer(language);

                    // show the expected answer by clicking div with class additional-content__item--item-info
                    setTimeout(() => {
                        var infoItem = document.querySelector('.additional-content__item--item-info');
                        if (infoItem) {
                            infoItem.click();
                            console.log('Item info clicked to show expected answer');
                        } else {
                            console.log('Item info not found');
                        }
                    }, 500);

                }

                // override to let user manually go to next question
                if(transcript === "next" || transcript === "次") {
                    const submitButton = document.querySelector('button.quiz-input__submit-button[data-quiz-input-target="button"]');
                    if (submitButton) {
                        submitButton.click();
                        console.log('Submit button clicked for wrong answer');
                    } else {
                        console.log('Submit button not found');
                    }
                }

                // override to let user mark something correct manually
                if(transcript === "correct" || transcript === "正しい") {
                    // get the proper answer depending on the language and quiz type
                    // Assuming 'currentSubject' holds the correct answer
                    var answer;
                    if (language === 'en') {
                        // If the language is English, we'll assume the correct answer is in 'meanings' array.
                        // This is just an example and might need to be adjusted based on your 'currentSubject' structure
                        answer = currentSubject.meanings[0].text; // or whichever index holds the correct answer
                    } else if (language === 'ja') {
                        // If the language is Japanese, we'll assume the correct answer is in 'readings' array.
                        // This is just an example and might need to be adjusted based on your 'currentSubject' structure
                        answer = currentSubject.readings[0].text; // or whichever index holds the correct answer
                    }
                    enterAnswerAndNext(answer);
                    return;
                }

                if (!currentSubject) {
                    console.log('Quiz subject not found. Waiting for the next question...');
                    return;
                }

                if (language === 'en') {
                    const allMeanings = [
                        ...currentSubject.meanings.map(m => m.text.toLowerCase())
                    ];
                    console.log(`Meanings: ${allMeanings}`);
                    for (const meaning of allMeanings) {
                        // TODO: if meaning includes "to", make sure that we dont just match "to", make sure we match "to x" verbatim within the transcript
                        if (transcript.includes(meaning)) {
                            console.log(`Match found for "${transcript}" with "${meaning}"`);
                            enterAnswerAndNext(meaning);
                            return;
                        }
                    }
                } else if (language === 'ja') {
                    let allReadings = [];
                    // if it's vocab, we can just check vs the actual kanji
                    if (currentSubject.type === 'Vocabulary') {
                        allReadings = [...currentSubject.readings.map(r => r.text), currentSubject.characters];
                        console.log(`Readings: ${allReadings}`);
                        for(const reading of allReadings){
                            if (transcript.includes(reading)) {
                                console.log(`Match found for "${transcript}" with "${reading}"`);
                                enterAnswerAndNext(allReadings[0]);
                                return;
                            }
                        }

                    // if its kanji, we need the phonetic options for all kanji in the sentance
                    } else if (currentSubject.type === 'Kanji') {
                        var desiredReadingType = currentSubject.primary_reading_type;

                        const validReadings = currentSubject.readings
                        .filter(r => r.type === desiredReadingType)
                        .map(r => r.text);

                        allReadings = [...validReadings];

                        console.log(`Readings: ${allReadings}`);

                        for(const reading of allReadings){
                            if (transcript.includes(reading)) {
                                console.log(`Match found for "${transcript}" with "${reading}"`);
                                enterAnswerAndNext(reading);
                                return;
                            }
                        }

                        // Get individual Kanji from the transcript
                        const kanjisInTranscript = splitIntoKanji(transcript);

                        // Fetch data for each Kanji and check if the reading matches
                        const kanjiDataPromises = kanjisInTranscript.map(kanji => fetchKanjiData(kanji));
                        Promise.all(kanjiDataPromises).then(kanjiDataArray => {

                            for (const reading of allReadings) {
                                for (const kanjiData of kanjiDataArray) {
                                    // Get both 'kun' and 'on' readings from the kanjiData
                                    const onKanjiReadings = kanjiData['on_readings'];
                                    const kunKanjiReadings = kanjiData['kun_readings'];

                                    // Concatenate the 'kun' and 'on' readings into one array
                                    // and convert each reading to both katakana and hiragana
                                    const kanjiReadings = [
                                        ...onKanjiReadings.flatMap(convertKana),
                                        ...kunKanjiReadings.flatMap(convertKana)
                                    ];

                                    for (const kanjiReading of kanjiReadings) {
                                        // Since convertKana returns an array, we should compare with kanjiReading[0] or kanjiReading[1]
                                        if (allReadings.includes(kanjiReading)) {
                                            console.log(`Match found for "${transcript}" with "${kanjiReading}"`);
                                            enterAnswerAndNext(kanjiReading);
                                            return;
                                        }
                                    }
                                }
                            }
                        });
                    }
                }
            }
        }
    };

    checkAll();
    const targetNode = document.querySelector('body');
    quizObserver.observe(targetNode, { childList: true, subtree: true });

    recognition.continuous = true;

    recognition.onend = function () {
        recognition.lang = language === 'ja' ? 'ja-JP' : 'en-US';
        recognition.start();
        console.log("rec language: " + recognition.lang);
    }

    console.log("Starting language recognition");
    recognition.lang = language === 'ja' ? 'ja-JP' : 'en-US';
    recognition.start();
    console.log("rec language: " + recognition.lang);

})();