HUST LMS Solver

Solve Moodle quiz questions using AI free-tier API

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         HUST LMS Solver
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Solve Moodle quiz questions using AI free-tier API
// @author       You
// @match        https://lms.hust.edu.vn/mod/quiz/attempt.php*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @connect      generativelanguage.googleapis.com
// @license      MIT
// ==/UserScript==

(function () {
    'use strict'

    const STORAGE_KEY = 'gemini_api_key'
    const MODEL = 'gemini-2.5-flash'
    const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models'

    function getApiKey() {
        let key = GM_getValue(STORAGE_KEY, '')
        if (!key) {
            key = prompt('Enter your Gemini API key (get one free at https://aistudio.google.com/app/apikey):')
            if (key) {
                GM_setValue(STORAGE_KEY, key)
            }
        }
        return key
    }

    function setApiKey() {
        const key = prompt(
            'Enter your Gemini API key:',
            GM_getValue(STORAGE_KEY, '')
        )
        if (key) {
            GM_setValue(STORAGE_KEY, key)
            GM_notification({ text: 'API key saved!', timeout: 2000 })
        }
    }

    function extractQuestions() {
        const questions = []
        const queDivs = document.querySelectorAll('.que')

        queDivs.forEach((que) => {
            const qnoEl = que.querySelector('.qno')
            if (!qnoEl) return

            const qno = qnoEl.textContent.trim()
            const qtext = que.querySelector('.qtext')?.textContent?.trim() || ''
            if (!qtext) return

            const type = que.classList.contains('multichoice')
                ? 'multichoice'
                : que.classList.contains('shortanswer')
                  ? 'shortanswer'
                  : 'unknown'

            if (type === 'multichoice') {
                const options = []
                const answerDiv = que.querySelector('.answer')
                if (answerDiv) {
                    const items = answerDiv.querySelectorAll('.r0, .r1')
                    items.forEach((item) => {
                        const label = item.querySelector('label')
                        if (label) {
                            const ansNum = label.querySelector('.answernumber')
                            const letter = (ansNum?.textContent || '')
                                .replace('.', '')
                                .trim()
                            const text = ansNum
                                ? label.textContent.replace(ansNum.textContent, '').trim()
                                : label.textContent.trim()
                            options.push({ letter, text })
                        }
                    })
                }
                questions.push({ qno, qtext, type, options })
            } else if (type === 'shortanswer') {
                questions.push({ qno, qtext, type })
            }
        })

        return questions
    }

    function buildPrompt(questions) {
        const lines = [
            'You are a Japanese language expert solving a quiz.',
            'Answer the following questions accurately and concisely.',
            '',
            '- For multiple choice: respond with the LETTER of the correct answer (e.g. "a", "b", "c").',
            '- For short answer: respond with the answer in hiragana (e.g. "ねます", "しっています")',
            '',
            'Return a valid JSON object with question numbers as keys and answers as values.',
            'Example: {"1": "c", "6": "ねます", "7": "しっています"}',
            '',
            'Questions:',
        ]

        questions.forEach((q) => {
            lines.push('')
            lines.push(`Question ${q.qno}: ${q.qtext}`)
            if (q.type === 'multichoice' && q.options) {
                q.options.forEach((opt) => {
                    lines.push(`  ${opt.letter}. ${opt.text}`)
                })
            }
        })

        return lines.join('\n')
    }

    function applyAnswers(answers) {
        const queDivs = document.querySelectorAll('.que')

        queDivs.forEach((que) => {
            const qnoEl = que.querySelector('.qno')
            if (!qnoEl) return

            const qno = qnoEl.textContent.trim()
            const answer = answers[qno]
            if (!answer) return

            const type = que.classList.contains('multichoice')
                ? 'multichoice'
                : que.classList.contains('shortanswer')
                  ? 'shortanswer'
                  : 'unknown'

            if (type === 'multichoice') {
                const items = que.querySelectorAll('.answer .r0, .answer .r1')
                items.forEach((item) => {
                    const label = item.querySelector('label')
                    if (!label) return
                    const ansNum = label.querySelector('.answernumber')
                    const letter = (ansNum?.textContent || '')
                        .replace('.', '')
                        .trim()
                        .toLowerCase()
                    const radio = item.querySelector('input[type="radio"]')
                    if (radio && letter === answer.toLowerCase()) {
                        radio.checked = true
                        radio.dispatchEvent(new Event('change', { bubbles: true }))
                    }
                })
            } else if (type === 'shortanswer') {
                const input = que.querySelector('input[type="text"]')
                if (input) {
                    input.value = answer
                    input.dispatchEvent(new Event('input', { bubbles: true }))
                    input.dispatchEvent(new Event('change', { bubbles: true }))
                }
            }
        })
    }

    async function solve() {
        const apiKey = getApiKey()
        if (!apiKey) {
            GM_notification({
                text: 'Set your Gemini API key via Tampermonkey menu → "Set Gemini API Key"',
                timeout: 4000,
            })
            return
        }

        const questions = extractQuestions()
        if (questions.length === 0) {
            GM_notification({ text: 'No questions found on this page.', timeout: 2000 })
            return
        }

        const prompt = buildPrompt(questions)
        const url = `${API_BASE}/${MODEL}:generateContent`

        const payload = {
            contents: [{ parts: [{ text: prompt }] }],
            generationConfig: {
                temperature: 0,
                responseMimeType: 'application/json',
            },
        }

        // Show loading state
        const btn = document.getElementById('gemini-solve-btn')
        if (btn) {
            btn.disabled = true
            btn.textContent = 'Solving...'
        }

        try {
            const response = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'POST',
                    url: url,
                    headers: {
                        'Content-Type': 'application/json',
                        'x-goog-api-key': apiKey,
                    },
                    data: JSON.stringify(payload),
                    onload: resolve,
                    onerror: reject,
                    ontimeout: reject,
                    timeout: 60000,
                })
            })

            if (response.status !== 200) {
                let errMsg = `API error (${response.status})`
                try {
                    const errData = JSON.parse(response.responseText)
                    errMsg += `: ${errData.error?.message || response.responseText}`
                } catch {
                    errMsg += `: ${response.responseText}`
                }
                alert(errMsg)
                return
            }

            const data = JSON.parse(response.responseText)
            const text = data?.candidates?.[0]?.content?.parts?.[0]?.text
            if (!text) {
                console.error('Unexpected Gemini response:', data)
                alert('Gemini returned an empty response. Check console for details.')
                return
            }

            // Parse JSON from response (handle markdown code fences)
            let cleaned = text.trim()
            cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '')
            cleaned = cleaned.trim()

            let answers
            try {
                answers = JSON.parse(cleaned)
            } catch (e) {
                alert(
                    'Failed to parse Gemini response as JSON.\n\nRaw response:\n' +
                        text.slice(0, 500)
                )
                return
            }

            applyAnswers(answers)
            GM_notification({
                text: `Solved ${Object.keys(answers).length} question(s)!`,
                timeout: 2000,
            })
        } catch (err) {
            console.error(err)
            alert(`Error: ${err.message || 'Request failed'}`)
        } finally {
            if (btn) {
                btn.disabled = false
                btn.textContent = 'Solve'
            }
        }
    }

    function addButton() {
        const submitBtns = document.querySelector('.submitbtns')
        if (!submitBtns) return

        const btn = document.createElement('button')
        btn.type = 'button'
        btn.id = 'gemini-solve-btn'
        btn.className = 'btn btn-primary mr-2'
        btn.textContent = 'Solve'
        btn.onclick = solve
        submitBtns.insertBefore(btn, submitBtns.firstChild)
    }

    GM_registerMenuCommand('⚙ Set Gemini API Key', setApiKey)

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', addButton)
    } else {
        addButton()
    }
})()