// ==UserScript== // @name Duolingo HearEverything // @namespace http://tampermonkey.net/ // @version 0.34 // @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 GM_setValue // @grant GM_getValue // @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 // 0.10.3: debug quirks from setting newPage // 0.11: cleaned up some code // 0.12: finally got rid of the new page problem // 0.13: show some debug infos on the page // 0.14: more working reading // 0.15: added more challenges to read // 0.16: Challenges, which work (some partially) // FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION, TAP_COMPLETE // 0.17: added shortcut ALT+l // 0.18: listening button for DIALOGUE and bugfixing TRANSLATE // 0.19: better listening button // 0.20: Voice selection // 0.21: cleaned up code // 0.22: challenge translate (tap) working // 0.22.1: no speaker button with translate from learning language // 0.23: Alt + l for Duo buttons, too // 0.24: tap-complete working // 0.25: form challenge working // 0.25.1: bugfix: challenge-translate // 0.26: challenge read-comprehension // 0.27: challenge name // 0.28: autoplay for challenge translate // 0.29: replace prompt at challenge gap fill // 0.30: gap fill auto play // 0.30.1: fixed playback stops // 0.31: stops playback on new page // 0.31.1: fixed gap fill not reading whole answer // 0.32: auto play for complete reverse translation // 0.33: toggle options readout at gap fill challenge // 0.34: challenge dialoge auto play, auto intro, play options /* // 0.30.2: TODO: move autoplay and config check/uncheck checkbox GM_setValue to own function // 0.30.3: TODO: move let utter to own function // TODO: close button / close with outside click on config bubble // TODO: config for challenges // TODO: replace gaps with correct answers FORM, // TODO: DIALOGUE should start with reading speaker 1 // TODO: maybe add listening to speaking experience? // TODO: clean up the script even more // 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 // TODO: popup could be toggled by mouseover/mouseout */ const VERSION = '0.34 -- 6'; const LOG_STRING = 'Duolingo HearEverything: '; const buttonPosition = 'bottom'; // bottom / top allowed let voiceSelect; let config = {}; const DEBUG = false; let synth = window.speechSynthesis; let voices = []; let newPage = false; let addedSpeech = false; let speakerButton = ` `; // Element definitions const WRONG_ANSWER_CLASS = '._1UqAr._1sqiF'; const RIGHT_ANSWER_CLASS = '._1UqAr._1Nmv6'; const RIGHT_CLASS = '._1Nmv6'; const WRONG_CLASS = '._1sqiF'; const ANSWER_HEADLINE = '._1x6Dk'; const ANSWER_CONTAINER = '._2ez4I'; const DIALOGUE_SPEAKER_CLASS = '_29e-M _39MJv _2Hg6H'; // currently used const ANSWER_CLASS = '._1UqAr'; const ANSWER = 'blame'; const ANSWER_QS = '[data-test~="' + ANSWER + '"]'; const RIGHT_ANSWER = 'blame-correct'; const RIGHT_ANSWER_QS = '[data-test~="' + RIGHT_ANSWER + '"]'; const WRONG_ANSWER = 'blame-incorrect'; const WRONG_ANSWER_QS = '[data-test~="' + WRONG_ANSWER + '"]'; const CHALLENGE_TAP_TOKEN = 'challenge-tap-token'; // challenge-translate (tap) const CHALLENGE_TAP_TOKEN_QS = '[data-test="' + CHALLENGE_TAP_TOKEN + '"]'; const WORD_BANK = 'word-bank'; // if exists it's tap instead of keyboard (challenge-translate) const WORD_BANK_QS = '[data-test="' + WORD_BANK + '"]'; const TRANSLATE_INPUT = 'challenge-translate-input'; const TRANSLATE_INPUT_QS = '[data-test="' + TRANSLATE_INPUT + '"]'; const SPEAKER_BUTTON = 'speaker-button'; const SPEAKER_BUTTON_QS = '[data-test="' + SPEAKER_BUTTON + '"]'; const HINT_SENTENCE = 'hint-sentence'; const HINT_SENTENCE_QS = '[data-test="' + HINT_SENTENCE + '"]'; const CHALLENGE_JUDGE = 'challenge-judge-text'; const CHALLENGE_JUDGE_QS = '[data-test="' + CHALLENGE_JUDGE + '"]'; const FORM_PROMPT = 'challenge-form-prompt'; const FORM_PROMPT_QS = '[data-test="' + FORM_PROMPT + '"]'; const RIGHT_OPTION_QS = '[aria-checked="true"] div'; const TEXT_INPUT = 'challenge-text-input'; const TEXT_INPUT_QS = '[data-test="' + TEXT_INPUT + '"]'; const SPEAK_INTRO = 'speakIntro'; const SPEAK_INTRO_QS = '#' + SPEAK_INTRO; // used page types const FORM = 'challenge challenge-form'; const TRANSLATE = 'challenge challenge-translate'; // almost the same than challenge-form // read both correct solutions //

Another correct solution:

On est ici.
const DIALOGUE = 'challenge challenge-dialogue'; //
// here to add a speaker button if I want for the entry text // or before the first data-test="hint-sentence" // text to read = document.querySelector('[data-test="hint-sentence"]').parentNode.innerText const READ_COMPREHENSION = 'challenge challenge-readComprehension'; const LISTEN_COMPREHENSION = 'challenge challenge-listenComprehension'; const COMPLETE_REVERSE_TRANSLATION = 'challenge challenge-completeReverseTranslation'; const TAP_COMPLETE = 'challenge challenge-tapComplete'; const GAP_FILL = 'challenge challenge-gapFill'; const NAME = 'challenge challenge-name'; // unused page types const LISTEN = 'challenge challenge-listen'; // Word bank stuff // is not read out by Duo all the time (but at "Tap what you hear"); const LISTEN_TAP = 'challenge challenge-listenTap'; // has word bank and some filled up answer to read // duo reads aloud const SELECT_TRANSCRIPTION = 'challenge challenge-selectTranscription'; // allowed challenge types const TEST = [FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION, TAP_COMPLETE, LISTEN_COMPREHENSION, READ_COMPREHENSION, NAME]; var buttonDisabled = true; function debug(s) { console.debug(LOG_STRING + s); } 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) //

${voices[i].name}`; } let styleCheckbox = 'style="vertical-align: bottom;"'; let configTranslate = `
Challenge Translate:
`; let configGapFill = `
Challenge Gap Fill:
`; let configDialogue = `
Challenge Dialogue:
`; configDiv.innerHTML = `
Language  
${configTranslate} ${configGapFill} ${configDialogue}
`; document.querySelector('[role="progressbar"]').insertAdjacentElement('afterend',configButton); configButton.insertAdjacentElement('afterend', configDiv); configButton.addEventListener('click', function () { togglePopout('hearEverythingConfig'); }); let configLanguage = document.getElementById('configLanguage') configLanguage.querySelector('[value="' + voiceSelect + '"]').setAttribute('selected', true); configLanguage.addEventListener('change', function() { voiceSelect = configLanguage.options[configLanguage.selectedIndex].value; GM_setValue('voiceSelect', voiceSelect); setVoice(); }); document.getElementById('he_ct_auto').checked = config.he_ct_auto; document.getElementById('he_cgf_auto').checked = config.he_cgf_auto; document.getElementById('he_cgf_click').checked = config.he_cgf_click; document.getElementById('he_cd_auto').checked = config.he_cd_auto; document.getElementById('he_cd_click').checked = config.he_cd_click; document.getElementById('he_cd_autointro').checked = config.he_cd_autointro; document.getElementById('hearEverythingConfig').addEventListener('change', function(e) { // console.debug(LOG_STRING + 'Target ID = ' + e.target.id); GM_setValue(e.target.id, e.target.checked); config[e.target.id] = e.target.checked; }); } } /* function configListener(e) { switch(e.target) { case 'he_ct_auto': GM_setValue('HearEverything_ct_autoplay', e.target.checked); */ function start() { if (document.querySelector('[data-test="challenge-header"]')) { addConfig(); buildDebug(); checkNewPage(); let challenge = getChallengeType(); if(challenge !== null) { // if we have an accepted challenge, we want to process this page, so we set newPage; // maybe replace it with blame exists and blame does not exist for addedSpeech ... if (newPage === true) { // ._3MD8I also available for wrong answers // better use blame if (document.querySelector(ANSWER_QS) !== null) { renderAnswerSpeakButton(); } else if((addedSpeech===false)&&(document.querySelectorAll(CHALLENGE_JUDGE_QS).length!==0)) { if (challenge[0] === LISTEN_COMPREHENSION) { // || challenge[0] === READ_COMPREHENSION) { let hint = document.querySelectorAll(HINT_SENTENCE_QS)[1].innerText.replace('...', ''); addSpeech(hint); addedSpeech = true; } if (challenge[0] === FORM || challenge[0] === DIALOGUE) { addSpeech(); addedSpeech = true; } if (challenge[0] === GAP_FILL && config.he_cgf_click === true) { addSpeech(); addedSpeech = true; } /* 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)'); */ } renderIntroSpeakButton(); } } else { // we detected no content to use, so we are not interested in this page newPage = false; } } // end challenge-header detection } function prepareChallengeGapFill() { let answer; if (document.querySelector(RIGHT_ANSWER_QS)) { answer = document.querySelector(RIGHT_OPTION_QS).innerText; } if (document.querySelector(WRONG_ANSWER_QS)) { let answerElement = document.querySelector(ANSWER_CLASS); if(answerElement.lastElementChild) { answer = answerElement.lastElementChild.innerText; } else { answer = answerElement.innerText; } } // question let read = document.querySelector(HINT_SENTENCE_QS).parentNode.innerText; // if the answer is at the start of the sentence, there's no \n if (read.includes('\n')) { read = read.replace('\n', answer); } else { read = answer + read; } document.querySelector(HINT_SENTENCE_QS).parentNode.innerHTML = `${read}`; return read; } function prepareChallengeForm() { let answer; if (document.querySelector(RIGHT_ANSWER_QS)) { answer = document.querySelector(RIGHT_OPTION_QS).innerText; } if (document.querySelector(WRONG_ANSWER_QS)) { let answerElement = document.querySelector(ANSWER_CLASS); if(answerElement.lastElementChild) { answer = answerElement.lastElementChild.innerText; } else { answer = answerElement.innerText; } } let read = document.querySelector(FORM_PROMPT_QS).getAttribute('data-prompt').replace(/_+/, answer); return read; /* let read; let solution = document.querySelector('._1UqAr'); // if it's the right solution, we get it from the selected choice 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'); } if (DEBUG) document.querySelector('#mySentence').innerText = read; return read; */ } function prepareChallengeName() { let read; if (document.querySelector(RIGHT_ANSWER_QS)) { read = document.querySelector(TEXT_INPUT_QS).value; } if (document.querySelector(WRONG_ANSWER_QS)) { read = document.querySelector(ANSWER_CLASS).innerText; } return read; } function prepareChallengeTranslate() { let read; if (document.querySelector(RIGHT_ANSWER_QS)) { if (document.querySelector(WORD_BANK_QS)) { read = document.querySelector(CHALLENGE_TAP_TOKEN_QS).parentNode.parentNode.innerText.replace(/\n/g, ' '); } else { let tI = document.querySelector(TRANSLATE_INPUT_QS); if (tI.lang === config.lang) read = tI.innerHTML; } } if (document.querySelector(WRONG_ANSWER_QS)) { let answer = document.querySelector(ANSWER_CLASS); if(answer.lastElementChild) { read = answer.lastElementChild.innerText; } else { read = answer.innerText; } } if (document.querySelector(SPEAKER_BUTTON_QS)) read = ''; // console.debug('HearEverything: read = ' + read); return read; /* if(document.querySelector('._1UqAr._1Nmv6')) { read = document.querySelector('._1UqAr._1Nmv6').innerText; } else if(document.querySelector('._1UqAr._1sqiF')) { if (document.querySelector('._1UqAr._1sqiF').lastElementChild) { read = document.querySelector('._1UqAr._1sqiF').lastElementChild.innerText; } else { read = document.querySelector('._1UqAr._1sqiF').innerText; } } else { let translateInput = document.querySelector('[data-test="challenge-translate-input"]'); if (translateInput !== null && translateInput.lang === lang) { //read = readTranslateInput(translateInput); read = translateInput.textContent; } } */ } function prepareChallengeTapComplete() { let read; if (document.querySelector(RIGHT_ANSWER_QS)) { read = document.querySelector(HINT_SENTENCE_QS).parentNode.innerText.replace(/\n/g, ' '); } if (document.querySelector(WRONG_ANSWER_QS)) { read = document.querySelector(ANSWER_CLASS).innerText; } // console.debug('HearEverything: read = ' + read); return read; } function prepareChallengeDialogue() { let read; //let hints = document.querySelectorAll('[data-test="hint-token"]'); let speaker1 = document.querySelector('[class="' + DIALOGUE_SPEAKER_CLASS + '"]').innerText; //hints.forEach(function(hint) { speaker1 += hint; }); let speaker2; if(document.querySelector(WRONG_ANSWER_QS)) { speaker2 = document.querySelector('._1UqAr._1sqiF').innerText; } else { speaker2 = document.querySelector('[aria-checked="true"]').querySelector('[data-test="challenge-judge-text"]').innerText; } read = speaker1 + '\n' + speaker2; return read } function introChallengeDialogue() { let read = document.querySelector(HINT_SENTENCE_QS).parentNode.innerText; let speaker = document.createElement('div'); speaker.innerHTML = speakerButton; speaker.children[0].id = SPEAK_INTRO; speaker.children[0].style = 'width:40px; height:40px; background:transparent; margin-left:-16px; margin-right:0px;padding-bottom:5px'; document.querySelector(HINT_SENTENCE_QS).insertAdjacentElement('beforeBegin', speaker); return read; } function prepareChallengeReadComprehension() { let read; //let hints = document.querySelectorAll('[data-test="hint-token"]'); let speaker1 = document.querySelector(HINT_SENTENCE_QS).innerText; //hints.forEach(function(hint) { speaker1 += hint; }); let speaker2; if(document.querySelector(WRONG_ANSWER_QS)) { speaker2 = document.querySelector(ANSWER_CLASS).innerText; } else { speaker2 = document.querySelector(RIGHT_OPTION_QS).innerText; } read = speaker1 + '\n' + document.querySelectorAll(HINT_SENTENCE_QS)[1].innerText.replace('...', ' ' +speaker2); return read } function renderIntroSpeakButton() { if (document.querySelector(SPEAK_INTRO_QS) === null) { let read = ''; let challenge = getChallengeType()[0]; if (challenge === DIALOGUE) { read = introChallengeDialogue(); } debug('intro = ' + read); let utter = generateUtter(read); addSpeakListener(SPEAK_INTRO, utter, read); if (config.he_cd_autointro) document.querySelector(SPEAK_INTRO_QS).click(); } } function renderAnswerSpeakButton() { // document.querySelector('._1UqAr._1Nmv6').innerText; Always the answer? // let test = [FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION]; // console.groupCollapsed('Add hearing abilities (input)'); // console.debug(getChallengeType()[0]); // console.debug('Solution detected'); let read = ''; let challenge = getChallengeType()[0]; if (challenge === FORM) { read = prepareChallengeForm(); /* let formPrompt = document.querySelector('[data-test="challenge-form-prompt"]'); if (formPrompt !== null) { read = readFormPrompt(formPrompt); } */ } if (challenge === TRANSLATE) { read = prepareChallengeTranslate(); } if (challenge === DIALOGUE) { read = prepareChallengeDialogue(); } if (challenge === READ_COMPREHENSION) { read = prepareChallengeReadComprehension(); } if (challenge === NAME) { read = prepareChallengeName(); } if (challenge === GAP_FILL) { read = prepareChallengeGapFill(); } if (challenge === COMPLETE_REVERSE_TRANSLATION) { read = prepareChallengeTranslate(); /* let translateInput = document.querySelector('[data-test="challenge-translate-input"]'); if (translateInput !== null && translateInput.lang === lang) { read = readTranslateInput(translateInput); } */ } if (challenge === TAP_COMPLETE) { read = prepareChallengeTapComplete(); } console.debug('HearEverything: read = ' + read); let utter = generateUtter(read); // add speaker button to answer and fill in the correct answer in the headline updateText(read); // if we have added the speaker button, we find it in the document addSpeakListener('speak', utter, read); // do it for every page to trigger the Duo speaker button also document.removeEventListener('keydown', myShortcutListener); document.addEventListener('keydown', myShortcutListener); newPage = false; // console.debug('Now it\'s an old page'); addedSpeech = false; // console.debug('Reset: speech isn\'t attached to options any more'); if (DEBUG) document.querySelector('#myOptions').innerText = 'disabled'; // if you like autoplay, it waits 1 second an plays it if (((challenge === TRANSLATE) || (challenge === COMPLETE_REVERSE_TRANSLATION)) && config.he_ct_auto === true) { timeoutAutoplay(challenge, utter); } if (challenge === GAP_FILL && config.he_cgf_auto === true) { timeoutAutoplay(challenge, utter); } if (challenge === DIALOGUE && config.he_cd_auto === true) { timeoutAutoplay(challenge, utter); } // console.groupEnd('Add hearing abilities (input)'); } function timeoutAutoplay(challenge, utter) { setTimeout(function() { console.debug(LOG_STRING + 'auto play ' + challenge); synth.cancel(); synth.speak(utter); },config.ap_timeout); } function addSpeakListener(id, utter, read) { let speak = document.querySelector('#' + id); if(speak) { speak.addEventListener('click',function () { synth.cancel(); synth.speak(utter); }); // console.debug('EventListener bound to speak button'); if (DEBUG) document.querySelector('#mySentence').innerText = read; document.getElementById(id).title = read; } else { console.debug('HearEverything: No speak button found'); } } function generateUtter(read) { let utter = new SpeechSynthesisUtterance(read); utter.voice = voices[voiceSelect]; utter.volume = 1; utter.pitch = 1; utter.rate = 1; utter.lang = config.lang; return utter; } function myShortcutListener(event) { let speak = document.querySelector('#speak'); let duoSpeak = document.querySelector(SPEAKER_BUTTON_QS); // ALT + l combo if (event.altKey && event.key === 'l') { if (speak) { speak.click(); } else if (duoSpeak) duoSpeak.click(); console.debug(LOG_STRING + 'alt = ' + event.altKey + ' + ' + event.key); } } function readTapComplete(tap) { let read = ''; let words = tap.childNodes; words.forEach(function(word) { if (word.nodeName === 'SPAN') read += word.children[0].innerText; if (word.nodeName === 'DIV') { read += word.querySelector('[class="_2Z2xv"]').children[0].innerText; } }); return read; } // gives some debug information directly in the Duo-GUI function buildDebug() { if(DEBUG) { if(!document.querySelector('#myChallenge')) { let debug = document.createElement('div'); debug.innerHTML = `Challenge-Name: ${getChallengeType(true)[0]} Sentence to speak: Speak options: disabled`; debug.style = "font-size: small; text-align:left; display:grid;"; document.querySelector('[data-test="challenge-header"]').insertAdjacentElement('afterend', debug); } } } function checkNewPage() { if(!document.querySelector('#myNewPage')) { let nP = document.createElement('div'); nP.id = 'myNewPage'; document.querySelector('[data-test="challenge-header"]').insertAdjacentElement('afterend', nP); //console.debug('---- div - newPage ----'); console.debug('HearEverything: Challenge Type = ' + getChallengeType(true)[0]); newPage = true; synth.cancel(); } else { //console.debug('---- div - oldPage ----'); } } function readTranslateInput(translateInput) { /* TODO: Word bank
*/ // does not exist on right answers?! let read = ''; let solution = document.querySelector(ANSWER_CLASS); if (document.querySelector(ANSWER_CONTAINER).childNodes.length === 1) { // right answer read = translateInput.innerHTML; console.debug('Translate Input: right answer'); /* another correct answer seems not to be recognized as correct answer it reads the other answer given not my answer

Another correct solution:

Une pizza, s'il vous plaît.
*/ } 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.
*/ let span = solution.querySelector('span'); if(span) { for (let i=0; i

You have a typo.

un passeport américain
*/ /* did not work for - it reads out "undefinedundefined"

Common mistake!

Correct solution: Tu prends la voiture ?
*/ console.debug('Translate Input: right answer with little mistake'); } else { /* // totally wrong answer let span = solution.querySelector('span'); if(span) { for (let i=0; i ${speakerButton} ${t}
`; console.debug('Speaker Button Top added'); } */ } else { /* if(formPrompt) { formPrompt.innerHTML = `
${t}
`; console.debug('Form Prompt Text changed'); } */ //if(formPrompt || (translateInput !== null && translateInput.lang === lang)) { if(TEST.includes(getChallengeType()[0])) { //
let div = document.createElement('div'); div.class = 'np6Tv'; div.style = 'position: absolute; align-self: flex-end; top: 1.8rem;'; div.innerHTML = speakerButton; // if the answer is displayed if(document.querySelector('._3dRS9._3DKa-._1tuLI')) { if (translateInput !== null) { if (translateInput.lang === config.lang) { document.querySelector('._3dRS9._3DKa-._1tuLI').insertAdjacentElement('afterBegin',div); // console.debug('Speaker Button Bottom added'); } } else { document.querySelector('._3dRS9._3DKa-._1tuLI').insertAdjacentElement('afterBegin',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 // //