Greasy Fork is available in English.
Solve Moodle quiz questions using AI free-tier API
// ==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()
}
})()