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      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.

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.