// ==UserScript== // @name Duolingo HearEverything // @namespace http://tampermonkey.net/ // @version 0.11 // @description Let's you hear phrases and words from single choice questions based on your browsers speech synthesis (right now only phrases). // @author Esh // @match https://*.duolingo.com/* // @grant none // @downloadURL none // ==/UserScript== // 0.7: Mutation Observer instead of setInterval // 0.6: Add voice to choices on click // 0.6.1: check why not the innerText of the answer is displayed in the full sentence? // 0.8.1: fix speaking numbers for options // 0.9: Move speak button near the continue button // 0.10.2: set better newPage = true - deleted // maybe bind it to the continue button // Continue // data-test="blame blame-correct" and blame-incorrect could be a good try // maybe bound it to the check button? // if check-button && reset = false newPage = true, reset = true // if read-button created reset = false // div class= _10vOG && data-test="player-next" && disabled // 0.10.3: debug quirks from setting newPage // TODO: clean up the script // TODO: translateInput // data-test="challenge-translate-input // but data-test="challenge challenge-listen" is not good // TODO: selectTranscription should not bind EventListeners because they already have some from Duo // data-test="challenge challenge-selectTranscription" // TODO: gapFill // data-test="challenge challenge-gapFill" to find out if it is such a challenge // TODO: data-test="challenge challenge-translate // data-test="challenge-translate-prompt // select words to a sentence, word bank // TODO: challenge dialogue // data-test="challenge challenge-dialogue" // looks like the same rules as challenge-form-prompt // data-test="challenge-choice" instead of aria-select? // TODO: completeReverseTranslation // data-test="challenge challenge-completeReverseTranslation" // make harder gives you: data-test="challenge-translate-input" // TODO: start also on first page // TODO: Tipp-Page // TODO: Hoots // TODO: selector where the speak button should be // TODO: translateInput speaker position top - where to place? // TODO: Voice selector (automagic) - 1/4 done // TODO: Add autoplay selector // more examples on the bottom const buttonPosition = 'bottom'; // bottom / top allowed const voiceSelect = 10; // insert voice number here const lang = 'fr'; const autoplay = false; /* Find your language number here 0: SpeechSynthesisVoice {voiceURI: "Microsoft Katja Online (Natural) - German (Germany)", name: "Microsoft Katja Online (Natural) - German (Germany)", lang: "de-DE", localService: false, default: true} 1: SpeechSynthesisVoice {voiceURI: "Microsoft Natasha Online (Natural) - English (Australia)", name: "Microsoft Natasha Online (Natural) - English (Australia)", lang: "en-AU", localService: false, default: false} 2: SpeechSynthesisVoice {voiceURI: "Microsoft Clara Online (Natural) - English (Canada)", name: "Microsoft Clara Online (Natural) - English (Canada)", lang: "en-CA", localService: false, default: false} 3: SpeechSynthesisVoice {voiceURI: "Microsoft Mia Online (Natural) - English (United Kingdom)", name: "Microsoft Mia Online (Natural) - English (United Kingdom)", lang: "en-GB", localService: false, default: false} 4: SpeechSynthesisVoice {voiceURI: "Microsoft Neerja Online (Natural) - English (India)", name: "Microsoft Neerja Online (Natural) - English (India)", lang: "en-IN", localService: false, default: false} 5: SpeechSynthesisVoice {voiceURI: "Microsoft Aria Online (Natural) - English (United States)", name: "Microsoft Aria Online (Natural) - English (United States)", lang: "en-US", localService: false, default: false} 6: SpeechSynthesisVoice {voiceURI: "Microsoft Guy Online (Natural) - English (United States)", name: "Microsoft Guy Online (Natural) - English (United States)", lang: "en-US", localService: false, default: false} 7: SpeechSynthesisVoice {voiceURI: "Microsoft Elvira Online (Natural) - Spanish (Spain)", name: "Microsoft Elvira Online (Natural) - Spanish (Spain)", lang: "es-ES", localService: false, default: false} 8: SpeechSynthesisVoice {voiceURI: "Microsoft Dalia Online (Natural) - Spanish (Mexico)", name: "Microsoft Dalia Online (Natural) - Spanish (Mexico)", lang: "es-MX", localService: false, default: false} 9: SpeechSynthesisVoice {voiceURI: "Microsoft Sylvie Online (Natural) - French (Canada)", name: "Microsoft Sylvie Online (Natural) - French (Canada)", lang: "fr-CA", localService: false, default: false} 10: SpeechSynthesisVoice {voiceURI: "Microsoft Denise Online (Natural) - French (France)", name: "Microsoft Denise Online (Natural) - French (France)", lang: "fr-FR", localService: false, default: false} 11: SpeechSynthesisVoice {voiceURI: "Microsoft Swara Online (Natural) - Hindi (India)", name: "Microsoft Swara Online (Natural) - Hindi (India)", lang: "hi-IN", localService: false, default: false} 12: SpeechSynthesisVoice {voiceURI: "Microsoft Elsa Online (Natural) - Italian (Italy)", name: "Microsoft Elsa Online (Natural) - Italian (Italy)", lang: "it-IT", localService: false, default: false} 13: SpeechSynthesisVoice {voiceURI: "Microsoft Nanami Online (Natural) - Japanese (Japan)", name: "Microsoft Nanami Online (Natural) - Japanese (Japan)", lang: "ja-JP", localService: false, default: false} 14: SpeechSynthesisVoice {voiceURI: "Microsoft SunHi Online (Natural) - Korean (Korea)", name: "Microsoft SunHi Online (Natural) - Korean (Korea)", lang: "ko-KR", localService: false, default: false} 15: SpeechSynthesisVoice {voiceURI: "Microsoft Colette Online (Natural) - Dutch (Netherlands)", name: "Microsoft Colette Online (Natural) - Dutch (Netherlands)", lang: "nl-NL", localService: false, default: false} 16: SpeechSynthesisVoice {voiceURI: "Microsoft Zofia Online (Natural) - Polish (Poland)", name: "Microsoft Zofia Online (Natural) - Polish (Poland)", lang: "pl-PL", localService: false, default: false} 17: SpeechSynthesisVoice {voiceURI: "Microsoft Francisca Online (Natural) - Portuguese (Brazil)", name: "Microsoft Francisca Online (Natural) - Portuguese (Brazil)", lang: "pt-BR", localService: false, default: false} 18: SpeechSynthesisVoice {voiceURI: "Microsoft Svetlana Online (Natural) - Russian (Russia)", name: "Microsoft Svetlana Online (Natural) - Russian (Russia)", lang: "ru-RU", localService: false, default: false} 19: SpeechSynthesisVoice {voiceURI: "Microsoft Emel Online (Natural) - Turkish (Turkey)", name: "Microsoft Emel Online (Natural) - Turkish (Turkey)", lang: "tr-TR", localService: false, default: false} 20: SpeechSynthesisVoice {voiceURI: "Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)", name: "Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)", lang: "zh-CN", localService: false, default: false} 21: SpeechSynthesisVoice {voiceURI: "Microsoft Yunyang Online (Natural) - Chinese (Mainland)", name: "Microsoft Yunyang Online (Natural) - Chinese (Mainland)", lang: "zh-CN", localService: false, default: false} 22: SpeechSynthesisVoice {voiceURI: "Microsoft HiuGaai Online (Natural) - Chinese (Hong Kong)", name: "Microsoft HiuGaai Online (Natural) - Chinese (Hong Kong)", lang: "zh-HK", localService: false, default: false} 23: SpeechSynthesisVoice {voiceURI: "Microsoft HsiaoYu Online (Natural) - Chinese (Taiwan)", name: "Microsoft HsiaoYu Online (Natural) - Chinese (Taiwan)", lang: "zh-TW", localService: false, default: false} 24: SpeechSynthesisVoice {voiceURI: "Microsoft Hedda - German (Germany)", name: "Microsoft Hedda - German (Germany)", lang: "de-DE", localService: true, default: false} 25: SpeechSynthesisVoice {voiceURI: "Microsoft Katja - German (Germany)", name: "Microsoft Katja - German (Germany)", lang: "de-DE", localService: true, default: false} 26: SpeechSynthesisVoice {voiceURI: "Microsoft Stefan - German (Germany)", name: "Microsoft Stefan - German (Germany)", lang: "de-DE", localService: true, default: false} */ var synth = window.speechSynthesis; var voices = []; //let parent = HTMLElement; var newPage = false; var addedSpeech = false; var speakerButton = ` `; // Element definitions const WRONG_ANSWER_CLASS = '._1UqAr._1sqiF'; const ANSWER_CLASS = '._1UqAr'; const RIGHT_ANSWER_CLASS = '._1UqAr._1Nmv6'; const RIGHT_CLASS = '._1Nmv6'; const WRONG_CLASS = '._1sqiF'; const ANSWER_HEADLINE = '._1x6Dk'; const ANSWER_CONTAINER = '._2ez4I'; // used page types // const FORM_PROMPT = 'challenge-form-prompt'; // const TRANSLATE_INPUT = 'challenge-translate-input'; const FORM = 'challenge challenge-form'; const TRANSLATE = 'challenge challenge-translate'; // unused page types // almost the same as challenge-form const GAP_FILL = 'challenge challenge-gapFill'; // almost the same as challenge-form const DIALOGUE = 'challenge challenge-dialogue'; const READ_COMPREHENSION = 'challenge challenge-readComprehension'; // almost the same as challenge-translate const COMPLETE_REVERSE_TRANSLATION = 'challenge challenge-completeReverseTranslation'; const LISTEN = 'challenge challenge-listen'; // duo reads aloud const SELECT_TRANSCRIPTION = 'challenge challenge-selectTranscription'; var buttonDisabled = true; function setVoice() { var duoState = JSON.parse(localStorage.getItem('duo.state')); var targetLang = duoState.user.learningLanguage; // console.log(duoState); //console.log(targetLang); } function start() { if (document.querySelector('[data-test="challenge-header"]')) { // console.debug(new Date().getTime() + ' - Mutation'); //buttonCheck(); let challenge = getChallengeType(); // we should wait after the first add of listeners for the user to click 'check' let formPrompt = document.querySelector('[data-test="challenge-form-prompt"]'); let translateInput = document.querySelector('[data-test="challenge-translate-input"]'); if(challenge!=null) { // if we have an accepted challenge, we want to process this page, so we set newPage; setNewPage(); if (newPage === true) { // if answer (right or wrong) is displayed // .GNWQJ instead of _1UqAr - so the element discussion to append is available // ._3MD8I also available for wrong answers if (document.querySelector('._3MD8I')!=null) { console.groupCollapsed('Add hearing abilities (input)'); console.debug(getChallengeType()[0]); console.debug('Solution detected'); let read = ''; if (formPrompt != null) { read = readFormPrompt(formPrompt); } else if (translateInput != null && translateInput.lang === lang) { // does not exist on right answers?! let solution = document.querySelector(ANSWER_CLASS); if (document.querySelector(ANSWER_CONTAINER).childNodes.length === 1) { // right answer //

Nice job!

read = translateInput.innerHTML; console.debug('Translate Input: right answer'); } else if (solution.classList.contains(RIGHT_CLASS)) { read = ''; // pay attention to accents // better read the solution here //

Pay attention to the accents.

J'habite en Europe.
// or //
Julia travaille à Paris.
if(solution.childNodes[0].nodeName === 'SPAN') { for (let i=0; i

You have a typo.

un passeport américain
console.debug('Translate Input: right answer with little mistake'); } else { // totally wrong answer //

Correct solution:

J'habite en Europe.
// Common mistake //

Common mistake!

Correct solution:J'habite en Europe.
//

Common mistake!

Correct solution:un passeport américain
// also wrong //

Correct solution:

un passeport américain
if(solution.childNodes[0].nodeName === 'SPAN') { read = solution.childNodes[1].innerHTML; console.debug('Translate Input: common mistake'); } else { read = solution.innerHTML; console.debug('Translate Input: wrong answer'); } } } console.debug('Listening to: ' + read); let utter = new SpeechSynthesisUtterance(read); utter.voice = voices[voiceSelect]; // synth.speak(utter); //dataPrompt.insertAdjacentHTML('afterend',``); updateText(read); let speak = document.querySelector('#speak'); if(speak) { speak.addEventListener('click',function(){synth.speak(utter);}); console.debug('EventListener bound to speak button'); } else { console.debug('No speak button found'); } newPage = false; console.debug('Now it\'s an old page'); addedSpeech = false; console.debug('Reset: speech isn\'t attached to options any more'); // if you like autoplay, it waits 1 second an plays it if (autoplay) setTimeout(synth.speak(utter),1000); console.groupEnd('Add hearing abilities (input)'); } else if((addedSpeech===false)&&(document.querySelectorAll('[aria-checked]').length!==0)) { console.groupCollapsed('Add hearing abilities (options)'); console.debug(getChallengeType()[0]); // if eventListeners were not bound yet addSpeech(); addedSpeech = true; console.debug('Now speech is attached to options'); console.groupEnd('Add hearing abilities (options)'); } } } else { // we detected no content to use, so we are not interested in this page newPage = false; document.querySelector('[data-test="player-skip"]') ? console.debug('Possible new page') : ''; } } // end challenge-header detection } function readFormPrompt(formPrompt) { let read; let solution = document.querySelector('._1UqAr'); // if it's the right solution, we get it from the selected choise if (solution.classList.contains('_1Nmv6')) { read = formPrompt.getAttribute('data-prompt').replace(/_+/,document.querySelector('[aria-checked="true"] div').innerText); console.debug('Form Prompt: right answer'); } else { // if it's wrong, we have to get it from the right solution display read = formPrompt.getAttribute('data-prompt').replace(/_+/,solution.innerText); console.debug('Form Prompt: wrong answer'); } return read; } // addes an eventListener to the continue button to set the newPage info function setNewPage() { /* // seems to work quite robust if (document.querySelector('[data-test="blame blame-correct"]') || document.querySelector('[data-test="blame blame-incorrect"]')) { console.debug('Add Event Listener on click for new page'); document.querySelector('._10vOG').querySelector('button').addEventListener('click', function() { newPage = true; console.debug('Reset: it\'s a new page'); }, {once:true}); } */ if (false) { console.group('Do we have to set newPage?'); console.group('Do we have to set newPage?'); console.debug('Header:\u0009 ' + document.querySelector('[data-test="challenge-header"]').firstElementChild.innerHTML); console.debug('newPage: ' + newPage); console.debug(document.querySelector('[data-test="player-skip"]') ? 'Skip:\u0009 ' + document.querySelector('[data-test="player-skip"]').innerHTML : 'Skip:\u0009 no button detected'); console.debug(document.querySelector('._10vOG').querySelector('button').disabled ? 'Check:\u0009 disabled' : 'Check:\u0009 enabled'); console.debug(document.querySelector('._3MD8I') ? 'Answer:\u0009 enabled' : 'Answer:\u0009 disabled'); getChallengeType(); console.groupEnd(); } // great, if it is to small, the skip button disapears :( if (document.querySelector('[data-test="player-skip"]') && newPage === false) { // just wait some milliseconds so the last challenge is vanished setTimeout(function() { if(getChallengeType()) { newPage = true; console.debug('New Page'); }}, 300); } // newPage for small sizes = no skip but deactivated button // not as relyable because check activated - deactivated - continue activated // newPage for challenge-dialogue = no skip but blue activated button /* to fast to be relyable if (document.querySelector('._10vOG').querySelector('button').disabled && !document.querySelector('._3MD8I') && newPage === false) { console.debug('Disabled Button detected'); console.debug(getChallengeType()[0]); newPage = true; } */ } // gets the type of the current challenge // returns array [type, HTMLElement] // returns null if no usable type is found function getChallengeType() { let type = null; let noType = 'Unidentified Page Type'; if (document.querySelector(`[data-test="${FORM}"]`)) { type = [FORM, document.querySelector(`[data-test="${FORM}"]`)]; } if (document.querySelector(`[data-test="${TRANSLATE}"]`)) { type = [TRANSLATE, document.querySelector(`[data-test="${TRANSLATE}"]`)]; } if (document.querySelector(`[data-test="${SELECT_TRANSCRIPTION}"]`)) { noType = 'No usable page type (' + SELECT_TRANSCRIPTION + ')'; } if (document.querySelector(`[data-test="${GAP_FILL}"]`)) { noType = 'No usable page type (' + GAP_FILL + ')'; } if (document.querySelector(`[data-test="${READ_COMPREHENSION}"]`)) { noType = 'No usable page type (' + READ_COMPREHENSION + ')'; } if (document.querySelector(`[data-test="${COMPLETE_REVERSE_TRANSLATION}"]`)) { noType = 'No usable page type (' + COMPLETE_REVERSE_TRANSLATION + ')'; } if (document.querySelector(`[data-test="${LISTEN}"]`)) { noType = 'No usable page type (' + LISTEN + ')'; } if (document.querySelector(`[data-test="${DIALOGUE}"]`)) { noType = 'No usable page type (' + DIALOGUE + ')'; } // type ? console.debug('Page Type: ' + type[0]) : console.debug(noType); return type; } // checks the check / continue button if we can use it for tracking different states // looks like the button goes from check enabled to disabled to continue enabled function buttonCheck() { let button = document.querySelector('._10vOG').querySelector('button'); if (button.disabled) { if(buttonDisabled) { console.groupCollapsed('Check / Continue Button (disabled)'); //console.debug('disabled'); console.debug(button); console.groupEnd('Check / Continue Button (disabled)'); buttonDisabled = false; } } else if (!buttonDisabled) { console.groupCollapsed('Check / Continue Button (enabled)'); //console.debug('enabled'); console.debug(button); console.groupEnd('Check / Continue Button (enabled)'); buttonDisabled = true; } } window.onload = function() { //_1UqAr - class for answer // data-test="challenge-form-prompt" - tag with fill-ins //data-prompt //aria-checked tag with fill-in options // _1Nmv6 - class for right answer // _1sqiF - class for wrong answer // data-test="challenge-translate-input" - free input field (lang="fr" / lang="en") // _1Nmv6 - class for right answer // _1UqAr - answer text (if wrong / typo) //

${speakerButton} ${t} `; console.debug('Speaker Button Top added'); } } else { if(formPrompt) { formPrompt.innerHTML = `
${t}
`; console.debug('Form Prompt Text changed'); } if(formPrompt || translateInput.lang === lang) { let div = document.createElement('div'); div.innerHTML = speakerButton; if(document.querySelector('._3MD8I')) { document.querySelector('._3MD8I').insertAdjacentElement('beforeend',div); console.debug('Speaker Button Bottom added') } } } } // Tipp Page //
// // Challenge-form //

Select the missing word

garçon mexicain
// // Error //

Correct solution:

un homme et un chat
// // Challenge-translate //
a
man
and
a
cat
//
// // completeReverseTranslation // //