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.

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

GitHub

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

GitHub

UdemyCopyFromSectionQuiz.js

1// ==UserScript==
2// @name         Udemy - Copy from Section Quiz
3// @namespace    http://tampermonkey.net/
4// @version      2.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 copyQuestionId = "userscript-added-button-copy-question";
21  const copyAnswerOptionsId = "userscript-added-button-copy-answer-options";
22  const copyAnswerId = "userscript-added-button-copy-answer";
23  const copyAdditionalInformationId =
24    "userscript-added-button-copy-additional-information";
25  const sharedContainerId = "userscript-shared-button-container";
26
27  const ourButtonIds = [
28    copyQuestionId,
29    copyAnswerOptionsId,
30    copyAnswerId,
31    copyAdditionalInformationId,
32    sharedContainerId,
33  ];
34
35  const selectors = {
36    quizPage: 'div[class^="compact-quiz-container--compact-quiz-container--"]',
37    nextQuestionButton: 'button[data-purpose="next-question-button"]',
38    quizFooter: 'div[class^="quiz-view--container--"] footer',
39    questionContainer: "#question-prompt",
40    answersContainer: "form ul",
41    possibleAnswersContainer: 'ul[aria-labelledby="question-prompt"]',
42  };
43
44  const udemyText = {
45    nextQuestion: {
46      check: "Check answer",
47      next: "Next",
48      results: "See results",
49    },
50  };
51
52  const appliedText = {
53    copyQuestion: "Copy Question",
54    copyPossibleAnswerTexts: "Copy Possible Options",
55    copyAnswer: "Copy Answer",
56    copyAdditionalInformation: "Copy explanation",
57  };
58
59  function ensureContainer() {
60    const quizFooter = document.querySelector(selectors.quizFooter);
61
62    let container = document.getElementById(sharedContainerId);
63    if (!container) {
64      container = document.createElement("div");
65      container.setAttribute("id", sharedContainerId);
66      container.style.display = "flex";
67      container.style.gap = "8px";
68      container.style.flexWrap = "wrap";
69      quizFooter?.append(container);
70    }
71    return container;
72  }
73
74  const callback = function (mutationsList) {
75    const addedNodes = mutationsList
76      .map((element) => {
77        return element.addedNodes;
78      })
79      .filter((nodeList) => nodeList.length > 0 && nodeList[0].id)
80      .map((node) => node[0].id);
81
82    const isOurMutation = addedNodes.reduce((prev, curr) => {
83      if (prev) return prev;
84
85      return ourButtonIds.includes(curr);
86    }, false);
87    if (isOurMutation) return;
88
89    const isQuizPage = document.querySelector(selectors.quizPage);
90    if (!isQuizPage) return;
91
92    const nextQuestionButton = document.querySelector(
93      selectors.nextQuestionButton
94    );
95    if (!nextQuestionButton) return;
96
97    const isQuestionStep =
98      nextQuestionButton.textContent === udemyText.nextQuestion.check;
99
100    const isAnswerStep =
101      nextQuestionButton.textContent === udemyText.nextQuestion.next ||
102      nextQuestionButton.textContent === udemyText.nextQuestion.results;
103
104    if (isQuestionStep) {
105      const container = ensureContainer();
106
107      if (document.getElementById(copyQuestionId)) return;
108      if (document.getElementById(copyAnswerOptionsId)) return;
109
110      // remove the copy answer button added from isAnswerStep
111      document.getElementById(copyAnswerId)?.remove();
112
113      // remove the copy additional information button added from isAnswerStep
114      document.getElementById(copyAdditionalInformationId)?.remove();
115
116      const questionContainer = document.querySelector(
117        selectors.questionContainer
118      );
119      const question = questionContainer.innerText;
120
121      const possibleAnswersContainer = document.querySelector(
122        selectors.possibleAnswersContainer
123      );
124      const possibleAnswers = Array.from(
125        possibleAnswersContainer.querySelectorAll("li")
126      )
127        .map((el) => el.innerText)
128        .join("\n\n");
129
130      const copyQuestionButton = document.createElement("button");
131      copyQuestionButton.setAttribute("id", copyQuestionId);
132      copyQuestionButton.innerHTML = appliedText.copyQuestion;
133      copyQuestionButton.addEventListener("click", () => {
134        navigator.clipboard.writeText(question);
135      });
136
137      const copyAnswerOptionsButton = document.createElement("button");
138      copyAnswerOptionsButton.setAttribute("id", copyAnswerOptionsId);
139      copyAnswerOptionsButton.innerHTML = appliedText.copyPossibleAnswerTexts;
140      copyAnswerOptionsButton.addEventListener("click", () => {
141        navigator.clipboard.writeText(possibleAnswers);
142      });
143
144      container.append(copyQuestionButton);
145      container.append(copyAnswerOptionsButton);
146
147      return;
148    }
149
150    if (isAnswerStep) {
151      const container = ensureContainer();
152
153      if (document.getElementById(copyAnswerId)) return;
154      if (document.getElementById(copyAdditionalInformationId)) return;
155
156      // remove the copy question button added from isAnswerStep
157      document.getElementById(copyQuestionId)?.remove();
158
159      // remove the copy possible options button added from isAnswerStep
160      document.getElementById(copyAnswerOptionsId)?.remove();
161
162      const answersContainer = document.querySelector(
163        selectors.answersContainer
164      );
165      if (!answersContainer) return;
166
167      const allAnswerContainers = answersContainer.querySelectorAll("li");
168      const correctAnswerContainers = Array.from(allAnswerContainers).filter(
169        (element) => {
170          const radioInput = element.querySelector("input[type=radio]");
171
172          return radioInput.checked;
173        }
174      );
175
176      const correctAnswers = correctAnswerContainers
177        .map((element) => element.querySelector("p").textContent)
178        .join("\n\n");
179
180      const copyAnswerButton = document.createElement("button");
181      copyAnswerButton.setAttribute("id", copyAnswerId);
182      copyAnswerButton.innerHTML = appliedText.copyAnswer;
183      copyAnswerButton.addEventListener("click", () => {
184        navigator.clipboard.writeText(correctAnswers);
185      });
186
187      container.append(copyAnswerButton);
188
189      const additionalInfo =
190        document
191          .querySelector('div[class*="alert-banner-module--body--"]')
192          ?.textContent.trim() || "";
193
194      if (additionalInfo) {
195        const additionalInfoButton = document.createElement("button");
196        additionalInfoButton.setAttribute("id", copyAdditionalInformationId);
197        additionalInfoButton.innerHTML = appliedText.copyAdditionalInformation;
198        additionalInfoButton.addEventListener("click", () => {
199          navigator.clipboard.writeText(additionalInfo);
200        });
201
202        container.append(additionalInfoButton);
203      }
204    }
205  };
206
207  // Create an observer instance linked to the callback function
208  const observer = new MutationObserver(callback);
209
210  // Start observing the target node for configured mutations
211  observer.observe(targetNode, config);
212})();
213

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.