Copy out Quizzes From Udemy With a UserScript

First I need to mention how much I love UserScripts. As the end-user when visiting a website you are in control of your personal view and how you interact with the website. Userscripts are a way to inject your own client-side JavaScript into any website. This allows you to edit the HTML, add new functionality, interact with APIs etc. Anything you can do with client-side JavaScript you can do in your UserScript.

On Firefox I utilise an add on called Tampermonkey to inject my UserScripts. Tampermonkey is also available as an extension on Chrome and most other popular browsers.

You can also utilise other people's UserScripts without writing your own, userscript.zone allows you to search for scripts by website name, Greasy Forkis an online host of UserScripts that can easily be installed. When installing someone else's code it is your responsibility to understand what you are installing.

Goal

Personally, I find myself getting easily distracted when studying for AWS exams on Udemy, by removing one of the more tedious steps I can focus better and not end up on Reddit/HackerNews.

The tedious step is that I would like to export quiz sections and practice tests for the AWS exam into a Spaced Repetition Learning (SRL) app. SRL isn't a service offered on the Udemy platform. (Udemy is an online education platform)

I personally use an app called Zorbi, Anki is another popular choice.

Unfortunately, it is not easy to highlight the text within Udemy quiz pages so I created a couple of UserScripts to make this process smoother for me. I have come across two types of quizzes so far on Udemy, quizzes that exist at the end of a course section and practice exam papers. For each quiz type I have a unique UserScript.

Video Demo

Copy from Practice Test

Copy from End of Section Quiz

End result in Zorbi

Code

Code for Copying from Practice Test

1// ==UserScript==
2// @name         Udemy - Copy from Practice Test
3// @namespace    http://tampermonkey.net/
4// @version      1.0
5// @description  Copy questions and answers from Udemy practice exams with ease
6// @author       John Farrell (https://www.johnfarrell.dev/)
7// @match        https://www.udemy.com/course/*
8// @icon         https://www.google.com/s2/favicons?sz=64&domain=udemy.com
9// ==/UserScript==
10
11(function() {
12    'use strict';
13
14    // Select the node that will be observed for mutations
15    const targetNode = document.querySelector('body');
16
17    // Options for the observer (which mutations to observe)
18    const config = { attributes: true, childList: true, subtree: true };
19
20    const callback = function(mutationsList, observer) {
21        // if muation is caused by our added button elements return to avoid infinte recursion
22        if(mutationsList.find(el => el.addedNodes[0]?.id === "userscript-added-button")) return;
23
24        const questionElementSelector = "div.detailed-result-panel--panel-row--2aE8z > form"
25        document.querySelectorAll(questionElementSelector)
26            .forEach(el => {
27
28            // if button already added to the question/answer form return
29            if(el.querySelector("#userscript-added-button")) return;
30
31            const question = el.querySelector("#question-prompt").textContent.trim()
32            const allAnswers = Array.from(el.querySelector("ul").querySelectorAll("p")).map(el => "	•	 " + el.textContent.trim()).join("
33
34");
35            const explanation = el.querySelector(".mc-quiz-question--explanation--Q5KHQ > div")?.textContent.trim()
36
37            const copyQuestionButton = document.createElement("button");
38            copyQuestionButton.setAttribute("id", "userscript-added-button")
39            copyQuestionButton.innerHTML = "Copy Question";
40
41            copyQuestionButton.addEventListener("click", () => {
42                navigator.clipboard.writeText(question + "
43
44" + allAnswers)
45            })
46
47            const copyAnswerButton = document.createElement("button");
48            copyAnswerButton.setAttribute("id", "userscript-added-button")
49            copyAnswerButton.innerHTML = "Copy Answer";
50
51            copyAnswerButton.addEventListener("click", () => {
52                navigator.clipboard.writeText(explanation)
53            })
54
55            el.querySelector(".unstyled-list").append(copyQuestionButton)
56            el.querySelector(".unstyled-list").append(copyAnswerButton)
57        })
58    };
59
60    // Create an observer instance linked to the callback function
61    const observer = new MutationObserver(callback);
62
63    // Start observing the target node for configured mutations
64    observer.observe(targetNode, config);
65})();

Code for Copying from End of Section Quiz

1// ==UserScript==
2// @name         Udemy - Copy from Section Quiz
3// @namespace    http://tampermonkey.net/
4// @version      1.0
5// @description  Easily copy questions and answers from Udemy section quizzes
6// @author       John Farrell (https://www.johnfarrell.dev/)
7// @match        https://www.udemy.com/course/*
8// @icon         https://www.google.com/s2/favicons?sz=64&domain=udemy.com
9// ==/UserScript==
10
11(function() {
12    'use strict';
13
14    // Select the node that will be observed for mutations
15    const targetNode = document.querySelector('body');
16
17    // Options for the observer (which mutations to observe)
18    const config = { attributes: true, childList: true, subtree: true };
19
20    const callback = function(mutationsList, observer) {
21        // return if mutationList contains mutations caused by us adding buttons, otherwise get infinite recursion until browser crashes
22        if(mutationsList.find(el => el.addedNodes[0]?.id === "userscript-added-button-copy-question" || el.addedNodes[0]?.id === "userscript-added-button-copy-answer")) return;
23
24        const isQuizPage = document.querySelector(".compact-quiz-container--compact-quiz-container--1BjpZ") !== null;
25        if(!isQuizPage) return;
26
27        const isQuestionStep = document.querySelector("button.udlite-btn-primary")?.textContent === "Check answer"
28        const isAnswerStep = document.querySelector("button.udlite-btn-small:nth-child(1)")?.textContent === "Next" || document.querySelector("button.udlite-btn-small:nth-child(1)")?.textContent === "See results"
29
30        const quizFooter = document.querySelector("div.curriculum-item-footer--flex-align-center--3ja06:nth-child(2)");
31
32        if(isQuestionStep) {
33
34            if(document.querySelector("#userscript-added-button-copy-question")) return
35
36            // remove the copy answer button added from isAnswerStep
37            const copyAnswerButton = document.querySelector("#userscript-added-button-copy-answer");
38            copyAnswerButton?.parentNode.removeChild(copyAnswerButton);
39
40            const question = document.querySelector("#question-prompt > p:nth-child(1)").innerText
41            const answers = Array.from(document.querySelectorAll("#udemy > div.main-content-wrapper > div.main-content > div > div > main > div > div.app--row--1ydzX.app--body-container--10gJo > div > div > div > div > div > div > div > div > div > div > div > div > div > div > form > ul > li > label > div.udlite-heading-md > div > div > p")).map((el) => "	• " + el.innerText);
42            const copyText = question + "
43
44" + answers.join("
45");
46
47            const copyQuestionButton = document.createElement("button");
48            copyQuestionButton.setAttribute("id", "userscript-added-button-copy-question");
49            copyQuestionButton.innerHTML = "Copy Question";
50            copyQuestionButton.addEventListener("click", () => {
51                navigator.clipboard.writeText(copyText)
52            })
53
54            quizFooter.append(copyQuestionButton);
55        }
56         else if(isAnswerStep) {
57
58             if(document.querySelector("#userscript-added-button-copy-answer")) return
59
60            const answers = Array.from(document.querySelectorAll('input[type=radio]'))
61                .filter(el => el.checked)
62                .map(el => el.parentElement.textContent.trim()).join("
63
64")
65            const addidtionalInfo = document.querySelector(".alert-banner--body--1ucrB")?.textContent.trim() || '';
66            const copyText = addidtionalInfo ? answers + "
67
68" + addidtionalInfo : answers;
69
70            const copyAnswerButton = document.createElement("button");
71            copyAnswerButton.setAttribute("id", "userscript-added-button-copy-answer");
72            copyAnswerButton.innerHTML = "Copy Answer";
73            copyAnswerButton.addEventListener("click", () => {
74                navigator.clipboard.writeText(copyText)
75            })
76
77            quizFooter.append(copyAnswerButton);
78
79             const nextQuestionSelector = 'button.udlite-btn-small:nth-child(1)'
80            document.querySelector(nextQuestionSelector).addEventListener("click", () => {
81                // remove the copy question button when we click to go to the next question
82                const copyQuestionButton = document.querySelector("#userscript-added-button-copy-question");
83                copyQuestionButton?.parentNode.removeChild(copyQuestionButton);
84            })
85        }
86    }
87
88
89    // Create an observer instance linked to the callback function
90    const observer = new MutationObserver(callback);
91
92    // Start observing the target node for configured mutations
93    observer.observe(targetNode, config);
94})();

Published on Greasy Fork

I have published both of these scripts to Greasy Fork. The benefit of this is ease of installation and any changes I push to GitHub should be automatically picked up by Greasy Fork.

Greasy Fork - Udemy Copy From Section Quiz

Greasy Fork - Udemy Copy From Practice Test

Lessons learned

By the time I got into web development the rise of the frameworks (Angular, Vue, React) had occurred, and I missed out on working with jQuery and vanilla JS. The frameworks are great but it is good to understand APIs offered by the browser and how to write vanilla JS to interact with the DOM (display object model). Working with UserScripts gives me some insight into web development without frameworks.

An example from the code I wrote in these two scripts is that I utilised the Mutation Observer class for the first time. Initially, when I first wrote this code I was utilising setTimeout and setInterval which felt hacky, I am far happier with the Mutation Observer implementation and it was a good tool to learn about.

I also learned when using the mutation observer that is triggering a function that edits the HTML you need to ignore your own HTML changes or you'll cause infinite recursion and crash your browser :p

When doing something like this I often think about the classic XKCD for time saved vs time spent. I think it is important to also value the learning experience, if I ever need to do something like this again I could now do it very quickly.

Is It Worth the Time?

Potential problems

Udemy may have a variety of quizzes that exist outside of the two I have written UserScripts for, I may need to amend or create new scripts to handle future possible variations.

The code is quite fragile, if Udemy change the HTML structure of their website or update CSS class names my scripts will break. Most likely it would only take a few minutes to update the code.

There are no automated tests for any of the code in the UserScripts. It would be possible to take a snapshot of the HTML from a Udemy quiz to test my code against. I could also configure fetching the HTML snapshot from a Udemy page periodically and then run my tests to automatically catch if the HTML structure has been updated and requires my code to be changed. I doubt I'll do any of this though, I've already procrastinated enough making the scripts and writing this instead of studying AWS.