How to Effortlessly Extract Udemy Quizzes with a Custom Userscript
Published: May 27, 2022
Last Updated: Jun 4, 2022
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 Fork is 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.
Contents
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
UdemyCopyFromTest.js
1// ==UserScript==
2// @name Udemy - Copy from Practice Test
3// @namespace http://tampermonkey.net/
4// @version 1.1
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 mutation is caused by our added button elements return to avoid infinite recursion
22 if (
23 mutationsList.find(
24 (el) => el.addedNodes[0]?.id === "userscript-added-button"
25 )
26 ) {
27 return;
28 }
29
30 const questionSections = Array.from(
31 document.querySelectorAll(
32 'div[class^="result-pane--question-result-pane-wrapper"]'
33 )
34 );
35 questionSections.forEach((el) => {
36 // if button already added to the question/answer form return
37 if (el.querySelector("#userscript-added-button")) return;
38
39 const question = el.querySelector("#question-prompt").textContent.trim();
40
41 const answerSection = el.querySelector(
42 'div[class^="result-pane--question-result-pane-expanded-content"]'
43 );
44
45 const allAnswers = Array.from(
46 answerSection.querySelectorAll(
47 'div[class^="answer-result-pane--answer-body"]'
48 )
49 )
50 .map((el) => el.textContent.trim())
51 .join("\n\n");
52
53 const correctAnswers = Array.from(
54 answerSection.querySelectorAll(
55 'div[class^="answer-result-pane--answer-correct"]'
56 )
57 )
58 .map((el) => {
59 return el
60 .querySelector('div[class^="answer-result-pane--answer-body"]')
61 .textContent.trim();
62 })
63 .join("\n\n");
64
65 const explanation = el
66 .querySelector("#overall-explanation")
67 ?.textContent.trim();
68
69 const copyQuestionButton = document.createElement("button");
70 copyQuestionButton.setAttribute("id", "userscript-added-button");
71 copyQuestionButton.innerHTML = "Copy Question";
72
73 copyQuestionButton.addEventListener("click", () => {
74 navigator.clipboard.writeText(question + "\n\n" + allAnswers);
75 });
76
77 const copyAnswerButton = document.createElement("button");
78 copyAnswerButton.setAttribute("id", "userscript-added-button");
79 copyAnswerButton.innerHTML = "Copy Answer";
80
81 copyAnswerButton.addEventListener("click", () => {
82 navigator.clipboard.writeText(correctAnswers + "\n\n" + explanation);
83 });
84
85 el.append(copyQuestionButton);
86 el.append(copyAnswerButton);
87 });
88 };
89
90 // Create an observer instance linked to the callback function
91 const observer = new MutationObserver(callback);
92
93 // Start observing the target node for configured mutations
94 observer.observe(targetNode, config);
95})();
96
Code for Copying from End of Section Quiz
UdemyCopyFromSectionQuiz.js
1// ==UserScript==
2// @name Udemy - Copy from Section Quiz
3// @namespace http://tampermonkey.net/
4// @version 1.1
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 (
23 mutationsList.find(
24 (el) =>
25 el.addedNodes[0]?.id === "userscript-added-button-copy-question" ||
26 el.addedNodes[0]?.id === "userscript-added-button-copy-answer"
27 )
28 ) {
29 return;
30 }
31
32 const isQuizPage =
33 document.querySelector(
34 'div[class^="compact-quiz-container--compact-quiz-container--"]'
35 ) !== null;
36 if (!isQuizPage) {
37 return;
38 }
39
40 const progressionButton = document.querySelector(
41 'button[data-purpose="next-question-button"]'
42 );
43
44 if (!progressionButton) {
45 return;
46 }
47
48 const isQuestionStep = progressionButton.textContent === "Check answer";
49 const isAnswerStep =
50 progressionButton.textContent === "Next" ||
51 progressionButton.textContent === "See results";
52
53 const quizFooter = document.querySelector(
54 'div[class^="curriculum-item-footer--flex-align-center--"] > div'
55 );
56
57 if (isQuestionStep) {
58 if (document.querySelector("#userscript-added-button-copy-question")) {
59 return;
60 }
61
62 // remove the copy answer button added from isAnswerStep
63 const copyAnswerButton = document.querySelector(
64 "#userscript-added-button-copy-answer"
65 );
66 copyAnswerButton?.parentNode.removeChild(copyAnswerButton);
67
68 const questionElement = document.querySelector("#question-prompt");
69 const question = questionElement.innerText;
70
71 const answerContainer = document.querySelector(
72 'ul[aria-labelledby="question-prompt"]'
73 );
74 const answers = Array.from(answerContainer.querySelectorAll("li")).map(
75 (el) => "\t• " + el.innerText
76 );
77
78 const copyText = question + "\n\n" + answers.join("\n");
79
80 const copyQuestionButton = document.createElement("button");
81 copyQuestionButton.setAttribute(
82 "id",
83 "userscript-added-button-copy-question"
84 );
85 copyQuestionButton.innerHTML = "Copy Question";
86 copyQuestionButton.addEventListener("click", () => {
87 navigator.clipboard.writeText(copyText);
88 });
89
90 quizFooter.append(copyQuestionButton);
91 } else if (isAnswerStep) {
92 if (document.querySelector("#userscript-added-button-copy-answer")) {
93 return;
94 }
95
96 const answers = Array.from(document.querySelectorAll("input[type=radio]"))
97 .filter((el) => el.checked)
98 .map((el) => el.parentElement.textContent.trim())
99 .join("\n\n");
100
101 const additionalInfo =
102 document
103 .querySelector('div[class*="alert-banner-module--body--"]')
104 ?.textContent.trim() || "";
105
106 const copyText = additionalInfo
107 ? answers + "\n\n" + additionalInfo
108 : answers;
109
110 const copyAnswerButton = document.createElement("button");
111 copyAnswerButton.setAttribute(
112 "id",
113 "userscript-added-button-copy-answer"
114 );
115 copyAnswerButton.innerHTML = "Copy Answer";
116 copyAnswerButton.addEventListener("click", () => {
117 navigator.clipboard.writeText(copyText);
118 });
119
120 quizFooter.append(copyAnswerButton);
121
122 const nextQuestionSelector =
123 'button[data-purpose="next-question-button"]';
124 document
125 .querySelector(nextQuestionSelector)
126 .addEventListener("click", () => {
127 // remove the copy question button when we click to go to the next question
128 const copyQuestionButton = document.querySelector(
129 "#userscript-added-button-copy-question"
130 );
131 copyQuestionButton?.parentNode.removeChild(copyQuestionButton);
132
133 const copyAnswerButton = document.querySelector(
134 "#userscript-added-button-copy-answer"
135 );
136 copyAnswerButton?.parentNode.removeChild(copyAnswerButton);
137 });
138 }
139 };
140
141 // Create an observer instance linked to the callback function
142 const observer = new MutationObserver(callback);
143
144 // Start observing the target node for configured mutations
145 observer.observe(targetNode, config);
146})();
147
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.
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.