// ==UserScript==
// @name Khan Academy
// @namespace https://enzopita.com
// @version 1.0.1
// @description Script do Khan Academy para responder automaticamente as questões
// @author Enzo Pita
// @match https://*.khanacademy.org/*
// @icon https://cdn.kastatic.org/images/favicon.ico
// @license MIT
// @run-at document-start
// @grant none
// ==/UserScript==
function later(delay, value) {
return new Promise(resolve => setTimeout(resolve, delay, value));
}
class Logger {
constructor(prefix = '') {
this.prefix = prefix;
this.colors = {
info: 'color: #00BFFF;',
warn: 'color: #FFD700;',
error: 'color: #FF4500;',
debug: 'color: #32CD32;',
};
}
info(...messages) {
this.#log('INFO', 'info', ...messages);
}
warn(...messages) {
this.#log('WARN', 'warn', ...messages);
}
error(...messages) {
this.#log('ERROR', 'error', ...messages);
}
debug(...messages) {
this.#log('DEBUG', 'debug', ...messages);
}
#log(level, colorKey, ...messages) {
const color = this.colors[colorKey] || '';
const formattedMessage = this.#formatMessage(level, ...messages);
console.log(`%c[${level}] %c${this.prefix} %c${formattedMessage}`, color, color, 'color: inherit;');
}
#formatMessage(level, ...messages) {
return messages.join(' ');
}
}
class Question {
constructor({ exerciseId, itemData }) {
this.logger = new Logger(`Question - ${exerciseId}`);
this.exerciseId = exerciseId;
this.itemData = itemData;
}
async answer() {
// TODO: Implementar "sorter"
const blocks = await this.#getBlocks();
const ignoredTypes = ['image'];
const handlers = {
'radio': this.answerRadio.bind(this),
'numeric-input': this.answerNumericInput.bind(this),
'input-number': this.answerInputNumber.bind(this),
'expression': this.answerExpression.bind(this),
'dropdown': this.answerDropdown.bind(this),
'categorizer': this.answerCategorizer.bind(this),
'interactive-graph': this.answerInterativeGraph.bind(this),
'grapher': this.answerInterativeGraph.bind(this),
};
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const handler = handlers[block.type];
if (!handler) {
// TODO: Exibir a resposta na interface, para possibilitar que o usuário responda manualmente.
if (!ignoredTypes.includes(block.type)) {
alert(`O script ainda não responde este tipo de questão automaticamente. Se quiser sugerir isso ao desenvolvedor, contate-o informando o tipo "${block.type}" e informe a URL da página atual.`);
}
this.logger.warn('Handler para o tipo', block.type, 'não foi encontrado.');
continue;
}
try {
await handler(block);
await later(100);
this.logger.info('Questão do tipo', block.type, 'foi respondido com sucesso.');
} catch (error) {
this.logger.error('Não foi possível responder automaticamente o campo do tipo', block.type);
console.error(error);
}
}
}
async answerCategorizer(block) {
const rows = block.element.querySelectorAll('tbody > tr');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const buttonIndex = block.data.options.values[i];
const columns = row.querySelectorAll('td:not(:first-child)');
const buttons = Array.from(columns).map(column => column.querySelector('.perseus-interactive'));
const button = buttons[buttonIndex];
if (!button) {
this.logger.warn('Botão não encontrado para o categorizer');
continue;
}
await later(50);
}
}
async answerRadio(block) {
const options = block.element.querySelectorAll('li');
for (let i = 0; i < block.data.options.choices.length; i++) {
const choice = block.data.options.choices[i];
if (!choice.correct) continue;
const element = options[i];
if (!element) {
throw new Error('Elemento não encontrado.');
}
const button = element.querySelector('button');
button.click();
await later(50);
}
}
async answerNumericInput(block) {
const acceptedAnswer = block.data.options.answers.find(answer => answer.status === 'correct');
if (!acceptedAnswer) {
return this.logger.warn('A questão não tem nenhum valor correto.');
}
const input = block.element.querySelector('input');
this.#simulateLatexPaste(input, acceptedAnswer.value.toString());
}
async answerInputNumber(block) {
const input = block.element.querySelector('input');
this.#simulateLatexPaste(input, block.data.options.value.toString());
}
async answerExpression(block) {
const textarea = block.element.querySelector('textarea');
const correctOption = block.data.options.answerForms.find(option => option.considered === 'correct');
if (!correctOption) {
throw new Error('Expressão correta não encontrada.');
}
this.#simulateLatexPaste(textarea, correctOption.value);
}
async answerDropdown(block) {
const toggleButton = block.element.querySelector('button');
toggleButton.click();
const dropdown = await this.#getDropdownPopper();
const buttons = Array.from(dropdown.querySelectorAll('button[aria-disabled="false"]'));
const correctOption = block.data.options.choices.find(choice => choice.correct);
if (!correctOption) {
throw new Error('Nenhuma opção encontrada.');
}
const option = buttons.find(element => {
const text = element.querySelector('div:last-child > span').innerText;
return text.trim() === correctOption.content.trim();
});
option.click();
}
async answerInterativeGraph(block) {
const coordinates = block.data.options.correct.coords;
const text = ['Coloque os seguintes pontos no gráfico:'];
for (const point of coordinates) {
const [x, y] = point;
text.push(`x: ${x} / y: ${y}`);
}
alert(text.join('\n'));
}
#simulateLatexPaste(element, expression) {
const originalValue = element.value;
const originalSelectionStart = element.selectionStart;
const originalSelectionEnd = element.selectionEnd;
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: new DataTransfer()
});
pasteEvent.clipboardData.setData('text/plain', expression);
element.dispatchEvent(pasteEvent);
if (element.value === originalValue) {
const start = originalSelectionStart;
const end = originalSelectionEnd;
element.setRangeText(expression, start, end, 'end');
}
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
}
#getPerseusWidgets() {
return new Promise((resolve, reject) => {
let retries = 0;
const interval = setInterval(() => {
if (retries >= 3) {
clearInterval(interval);
return reject();
}
const elements = document.querySelectorAll('.perseus-widget-container');
if (elements) {
clearInterval(interval);
return resolve(elements);
}
retries++;
});
});
}
#getDropdownPopper() {
return new Promise((resolve, reject) => {
let retries = 0;
const interval = setInterval(() => {
if (retries >= 3) {
clearInterval(interval);
return reject();
}
const element = document.querySelector('div[data-testid="dropdown-popper"]');
if (element) {
clearInterval(interval);
return resolve(element);
}
retries++;
});
});
}
async #getBlocks() {
const { content, widgets } = this.itemData.question;
const elementsLists = await this.#getPerseusWidgets();
let i = 0;
const blockRegex = /\[\[☃ ([\w-]+) (\d+)\]\]/g;
const blocks = [];
content.replace(blockRegex, (_, type, position) => {
const widgetKey = `${type} ${position}`;
const data = widgets[widgetKey];
const element = elementsLists[i++];
blocks.push({
type,
data,
element,
});
});
return blocks;
}
}
class QuestionManager {
constructor() {
this.logger = new Logger(QuestionManager.name);
this.reset();
}
addQuestion(question) {
this.questions.push(question);
this.logger.info('Uma nova questão foi adicionada, total de questões:', this.questions.length);
}
getQuestion() {
return this.questions[this.currentIndex];
}
nextQuestion() {
this.currentIndex++;
this.logger.info('O índice da questão atual foi incrementado. Índice atual:', this.currentIndex);
}
reset() {
this.currentIndex = 0;
this.questions = [];
this.logger.info('A lista de questões foi resetada.');
}
}
class NetworkMonitor {
constructor({
onAssessmentItem,
onAttemptProblem,
onRestartTask,
} = {}) {
this.logger = new Logger(NetworkMonitor.name);
this.lastQuestionUrl = this.#isTaskUrl() ? location.href : null;
this.originalFetch = window.fetch;
this.originalPushState = history.pushState;
this.originalReplaceState = history.replaceState;
this.onAssessmentItem = onAssessmentItem;
this.onAttemptProblem = onAttemptProblem;
this.onRestartTask = onRestartTask;
}
start() {
const monitorThis = this;
// Se o usuário sair página de questão, reseta a lista.
const handleUrlChange = () => {
if (monitorThis.#isTaskUrl()) {
// Se a questão mudar, apaga as questões relacionadas da questão anterior
// Já que o Khan Academy não apaga o cache se eu voltar no histórico
if (monitorThis.lastQuestionUrl && monitorThis.lastQuestionUrl !== location.href) {
monitorThis.onRestartTask();
}
monitorThis.lastQuestionUrl = location.href;
}
};
window.history.pushState = function(...args) {
monitorThis.originalPushState.apply(this, args);
handleUrlChange();
};
window.history.replaceState = function(...args) {
monitorThis.originalReplaceState.apply(this, args);
handleUrlChange();
};
window.fetch = function (request) {
// Se o parâmetro informado for uma URL, nós permitimos a requisição sem qualquer interceptação
// Isso acontece porque as requisições de interesse são do client GraphQL, que são enviadas através da classe Request
if (typeof request === 'string') {
return monitorThis.originalFetch.apply(this, arguments);
}
const requestClone = request.clone();
return monitorThis.originalFetch.apply(this, arguments).then(async (response) => {
const responseClone = response.clone();
const isAssessmentUrl = request.url.includes('/getAssessmentItem');
const isAttemptUrl = request.url.includes('/attemptProblem');
const isRestartTaskUrl = request.url.includes('/RestartTask');
const shouldIntercept = isAssessmentUrl || isAttemptUrl || isRestartTaskUrl;
if (shouldIntercept) {
const requestBody = await monitorThis.#decodeStream(requestClone.body).then(JSON.parse);
const responseBody = await responseClone.json();
if (isAssessmentUrl && monitorThis.onAssessmentItem) {
const itemData = JSON.parse(responseBody.data.assessmentItem.item.itemData);
// Atualiza o conteúdo da questão, desativando o randomize e facilitando a correlação
const updatedItemData = monitorThis.#updateItemData(itemData);
responseBody.data.assessmentItem.item.itemData = JSON.stringify(updatedItemData);
const modifiedResponseBody = JSON.stringify(responseBody);
const modifiedResponse = new Response(modifiedResponseBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
const { exerciseId } = requestBody.variables.input;
const question = new Question({
exerciseId,
itemData: updatedItemData,
});
// Executa o callback informando a nova questão obtida
monitorThis.onAssessmentItem(question);
return modifiedResponse;
}
if (isAttemptUrl && monitorThis.onAttemptProblem) {
const { attemptCorrect } = responseBody.data.attemptProblem.result.actionResults;
monitorThis.onAttemptProblem(attemptCorrect);
}
if (isRestartTaskUrl && monitorThis.onRestartTask) {
monitorThis.onRestartTask();
}
}
return response;
});
};
}
stop() {
window.fetch = this.originalFetch;
history.pushState = this.originalPushState;
history.replaceState = this.originalReplaceState;
}
#isTaskUrl(url = location.href) {
const path = url.replace(/^https?:\/\/[^\/]+\//, '');
const segments = path.split('/');
return segments.length > 2 && path.includes('/e/');
}
#updateItemData(itemData) {
const { widgets } = itemData.question;
for (const key of Object.keys(widgets)) {
const widget = widgets[key];
if (widget.type === 'radio') {
widget.options.randomize = false;
}
if (widget.type === 'categorizer') {
widget.options.randomizeItems = false;
}
}
return itemData;
}
async #decodeStream(readableStream) {
const reader = readableStream.getReader();
const decoder = new TextDecoder();
let result = '';
let done = false;
while (!done) {
const { value, done: streamDone } = await reader.read();
done = streamDone;
if (value) {
result += decoder.decode(value, { stream: !done });
}
}
return result;
}
}
const questionManager = new QuestionManager();
const networkMonitor = new NetworkMonitor({
onAssessmentItem: (question) => questionManager.addQuestion(question),
onAttemptProblem: (success) => {
if (success) {
questionManager.nextQuestion();
}
},
onRestartTask: () => questionManager.reset(),
});
networkMonitor.start();
// Expõe as variáveis para manipulação no DevTools
window.questionManager = questionManager;
window.networkMonitor = networkMonitor;
// Teclas temporárias
document.addEventListener('keydown', async (event) => {
if (event.key === 'N' || event.key === 'n') {
await window.questionManager.getQuestion().answer();
}
});