Khan Academy

Script do Khan Academy para responder automaticamente as questões

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
    }
});