From 69fb65deb77962a95cb2eac3a6873ff5b96ff312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20S=C3=A4ume?= Date: Thu, 23 May 2024 14:01:51 +0000 Subject: [PATCH] Setup --- README.md | 19 +++++ jsconfig.json | 7 ++ src/answer.js | 72 ++++++++++++++++++ src/fetch.js | 144 +++++++++++++++++++++++++++++++++++ src/main.user.js | 38 +++++++++ types/Answer.d.js | 6 ++ types/GM_getValue.d.js | 7 ++ types/GM_setValue.d.js | 5 ++ types/GM_xmlhttpRequest.d.js | 17 +++++ 9 files changed, 315 insertions(+) create mode 100644 README.md create mode 100644 jsconfig.json create mode 100644 src/answer.js create mode 100644 src/fetch.js create mode 100644 src/main.user.js create mode 100644 types/Answer.d.js create mode 100644 types/GM_getValue.d.js create mode 100644 types/GM_setValue.d.js create mode 100644 types/GM_xmlhttpRequest.d.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..af857e5 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# CCNA Autofill Userscript + +A Userscript for autofilling the Answers on the **CCNA**. +inspired by [Merlinfuchs/ccna-extension](https://github.com/merlinfuchs/ccna-extension). + +## Installation +Use a userscript client like [ViolentMonkey](https://violentmonkey.github.io/), [Tampermonkey](https://www.tampermonkey.net/). + +[![Install USerscript](https://img.shields.io/badge/Install_Userscript-Install_Userscript?style=for-the-badge&logo=tampermonkey&logoColor=brown&color=lightgray)](https://git.euph.dev/SZUT-Dominik/CCNA_Autofill_Userscript/raw/branch/main/src/main.user.js) + +## Status + +This Userscript is only tested with [ViolentMonkey](https://violentmonkey.github.io/) on the **CCNAv7**. + +## Usage + +- **p**: Paste your answer-URL from [ItexamAnswers](https://itexamanswers.net) for the **CCNAv7** use [these](https://itexamanswers.net/ccna-1-v7-modules-1-3-basic-network-connectivity-and-communications-exam-answers.html). +- **a**: Try autofilling the current answer. There won't be any visual feedback when it fails. +- **n**: Go to the next Question. \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..f90ecb7 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "checkJs": true, + "target": "es6", + "lib": ["dom", "es6"] + } +} diff --git a/src/answer.js b/src/answer.js new file mode 100644 index 0000000..e3893f8 --- /dev/null +++ b/src/answer.js @@ -0,0 +1,72 @@ +/** + * + * @param {Array} answerData + * @returns + */ +function answerQuestion(answerData){ + const question = document.querySelector(".question:not(.hidden)"); + if (!question) { + return; + } + + const questionTextDom = question.querySelector(".questionText .mattext"); + if (!questionTextDom) return; + const questionText = questionTextDom.textContent.trim(); + + const answersDom = question.querySelector("ul.coreContent"); + if (!answersDom) return; + const answers = answersDom.children; + + for (let answer of Array.from(answers)) { + const input = answer.querySelector("input"); + if (!input) continue; + input.checked = false; + } + + const correctAnswers = findAnswers(answerData, questionText, answers); + if (correctAnswers.length === 0) { + return; + } + + for (const answer of correctAnswers) { + const input = answer.querySelector("input"); + if (!input) continue; + input.checked = true; + } +} + +/** + * + * @param {Array} answerData + * @param {string} questionText + * @returns + */ +function findAnswers(answerData, questionText, answers) { + if (answerData === null) { + return []; + } + + const correctAnswers = []; + for (let entry of answerData) { + if (matchAnswer(questionText.trim(), entry.question.trim())) { + for (let availableAnswer of answers) { + for (let possibleAnswer of entry.answers) { + if (matchAnswer(availableAnswer.textContent.trim(), possibleAnswer)) { + correctAnswers.push(availableAnswer); + } + } + } + } + } + + return correctAnswers; +} + +function matchAnswer(textA, textB) { + const replaceRegex = /[^\w]/gi; + textA = textA.replace(replaceRegex, ""); + textB = textB.replace(replaceRegex, ""); + return (textA === textB); +} + +window.answerQuestion = answerQuestion; \ No newline at end of file diff --git a/src/fetch.js b/src/fetch.js new file mode 100644 index 0000000..a1a2e03 --- /dev/null +++ b/src/fetch.js @@ -0,0 +1,144 @@ +const QUESTION_REGEX = /^[0-9]+\. (.*)$/; + +/** + * Fetches answers from the specified URL. + * @param {string} [answerURL=""] - The URL to fetch answers from. + * @returns {Promise>} A Promise that resolves with the fetched answers. + */ +function fetchAnswers(answerURL = "") { + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: "GET", + url: answerURL, + headers: { + "Content-Type": "text/html", + }, + onload: function (response) { + parseAnswers(response, resolve); + }, + onerror: function (error) { + reject(error); + }, + }); + }); +} + +/** + * + * @param {GMXMLHttpRequestResponse} response + * @param {(value: Answer[] | PromiseLike) => void} resolve + */ +function parseAnswers(response, resolve) { + const results = []; + const allAnswersElement = getAllAnswersElement(response); + + let index = -1; + for (let child of Array.from(allAnswersElement.children)) { + index++; + const result = parseAnswerElement(index, child, allAnswersElement); + if (result != undefined) { + results.push(result); + } + } + + resolve(results); +} + +/** + * @param {GMXMLHttpRequestResponse} response + * @returns {Element} + */ +function getAllAnswersElement(response) { + const parser = new DOMParser(); + const virtualDOM = parser.parseFromString(response.responseText, "text/html"); + + let answersElement = virtualDOM.querySelector(".pf-content"); + if (!answersElement) { + answersElement = virtualDOM.querySelector(".thecontent"); + } + return answersElement; +} + +/** + * @param {number} index + * @param {Element} allAnswersElement + * @param {Element} element + * @returns {Answer} + */ +function parseAnswerElement(index, element, allAnswersElement) { + // Check for Possible Tags + if ( + !(element.tagName === "P" || element.tagName === "STRONG") || + !element.innerHTML + ) { + return; + } + + // Get Question Element + /** @type {Element} */ + let questionElement = element.querySelector("strong"); + if (questionElement === null) { + if (!element.textContent) { + return; + } + questionElement = element; + } + + // Get Question + const questionText = parseQuestion(questionElement); + if (questionText === null) { + return; + } + + // Get Awsners + const answersElement = getAnswersElement(index, allAnswersElement); + if (answersElement === null || answersElement.tagName !== "UL") return; + + return { + question: questionText, + answers: getAnswers(answersElement), + }; +} + +/** + * @param {Element} questionElement + * @returns {String} + */ +function parseQuestion(questionElement) { + const textContent = questionElement.textContent.trim(); + const matches = textContent.match(QUESTION_REGEX); + return matches !== null ? matches[1] : null; +} + +/** + * @param {number} index + * @param {Element} allAnswersElement + * @returns {Element} + */ +function getAnswersElement(index, allAnswersElement) { + let answersElement = allAnswersElement.children[index + 1]; + + if (answersElement.tagName === "P") { + answersElement = allAnswersElement.children[index + 2]; + } + return answersElement; +} + +/** + * @param {Element} answersElement + * @returns {Array} + */ +function getAnswers(answersElement) { + const answers = []; + for (let answerDom of Array.from(answersElement.querySelectorAll("strong"))) { + let answerText = answerDom.textContent.trim(); + if (answerText.endsWith("*")) { + answerText = answerText.substring(0, answerText.length - 1); + } + answers.push(answerText); + } + + return answers; +} + +window.fetchAnswers = fetchAnswers; diff --git a/src/main.user.js b/src/main.user.js new file mode 100644 index 0000000..6658050 --- /dev/null +++ b/src/main.user.js @@ -0,0 +1,38 @@ +// ==UserScript== +// @name CCNA Autofill +// @namespace Violentmonkey Scripts +// @match *://assessment.netacad.net/* +// @match *://www.assessment.netacad.net/* +// @match *://www.google.com/* +// @match *://www.google.de/* +// @require https://git.euph.dev/SZUT-Dominik/CCNA_Autofill_Userscript/raw/branch/main/src/fetch.js +// @require https://git.euph.dev/SZUT-Dominik/CCNA_Autofill_Userscript/raw/branch/main/src/answer.js +// @grant GM_setValue +// @grant GM_getValue +// @grant GM_xmlhttpRequest +// @version 1.0.0 +// @author Dominik Säume +// ==/UserScript== + +const URL_STORAGE_KEY = "itexamanswers.net URL"; +/** @type {Array} */ +let answerData; + +window.addEventListener("keydown", async (event) => { + switch(event.key){ + case "p": + const oldAnswersURL = GM_getValue(URL_STORAGE_KEY); + const newAnswersURL = prompt("Please input the answer url (itexamanswers.net)", oldAnswersURL); + GM_setValue(URL_STORAGE_KEY, newAnswersURL); + answerData = await window.fetchAnswers(newAnswersURL); + break; + + case "n": + document.getElementById("next").click(); + break; + + case "a": + window.answerQuestion(answerData); + break; + } +}); \ No newline at end of file diff --git a/types/Answer.d.js b/types/Answer.d.js new file mode 100644 index 0000000..d6d11c0 --- /dev/null +++ b/types/Answer.d.js @@ -0,0 +1,6 @@ +/** + * Object representing a question with answers. + * @typedef {Object} Answer + * @property {string} question - The question. + * @property {Array} answers - An array of answers. + */ diff --git a/types/GM_getValue.d.js b/types/GM_getValue.d.js new file mode 100644 index 0000000..d0bd584 --- /dev/null +++ b/types/GM_getValue.d.js @@ -0,0 +1,7 @@ +/** + * @param {string} storageKey + * @returns {string} + */ +function GM_getValue(storageKey) { + return ""; +} diff --git a/types/GM_setValue.d.js b/types/GM_setValue.d.js new file mode 100644 index 0000000..a003dd5 --- /dev/null +++ b/types/GM_setValue.d.js @@ -0,0 +1,5 @@ +/** + * @param {string} storageKey + * @param {string} value + */ +function GM_setValue(storageKey, value) {} diff --git a/types/GM_xmlhttpRequest.d.js b/types/GM_xmlhttpRequest.d.js new file mode 100644 index 0000000..b2382da --- /dev/null +++ b/types/GM_xmlhttpRequest.d.js @@ -0,0 +1,17 @@ +/** + * Makes an HTTP request using GM.xmlHttpRequest. + * @param {Object} details - Details of the HTTP request. + * @param {string} details.method - The HTTP request method (e.g., 'GET', 'POST'). + * @param {string} details.url - The URL to send the request to. + * @param {Object} [details.headers] - Additional headers to include in the request. + * @param {function(GMXMLHttpRequestResponse):void} [details.onload] - Optional. Will be called when the request has completed successfully. Passed one argument, the Response Object. + * @param {Function} [details.onerror] - Optional. Will be called if an error occurs while processing the request. Passed one argument, the Response Object. + * @returns {undefined} + */ +function GM_xmlhttpRequest(details) {} + +/** + * Response object for the HTTP request. + * @typedef {Object} GMXMLHttpRequestResponse + * @property {string} responseText - The response body as text. + */