// ==UserScript==
// @name Quizizz Hypertool
// @namespace http://tampermonkey.net/
// @version 1.3.1
// @description Extracts text/images from Quizizz, adds DDG links, Gemini AI helper, polls page info for changes.
// @author Nyxie
// @license GPL-3.0
// @match https://quizizz.com/*
// @grant GM_addStyle
// @grant GM_log
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @connect media.quizizz.com
// @connect generativelanguage.googleapis.com
// ==/UserScript==
(function() {
'use strict';
const GEMINI_API_KEY_STORAGE = 'GEMINI_API_KEY';
const GEMINI_MODEL = 'gemini-2.5-flash-preview-04-17'; // Consider Gemini 2.0 Flash if this one is slow
GM_addStyle(`
.quizizz-ddg-link, .quizizz-gemini-button {
/* ... general button styles ... */
display: inline-block; padding: 5px 8px; background-color: #4CAF50;
color: white; text-decoration: none; border-radius: 4px; font-size: 0.8em;
cursor: pointer; text-align: center; vertical-align: middle;
transition: opacity 0.2s ease-in-out;
}
.quizizz-ddg-link:hover, .quizizz-gemini-button:hover { opacity: 0.85; }
.quizizz-gemini-button { background-color: #007bff; margin-left: 5px; }
/* --- Option Button Wrapper and DDG Link Styling --- */
.quizizz-option-wrapper {
display: flex; /* Enable flexbox for vertical stacking */
flex-direction: column; /* Stack button and DDG link */
align-items: stretch; /* Make children stretch to full width */
justify-content: space-between; /* Push button up, DDG link down */
height: 100%; /* Wrapper takes full height of its grid cell */
}
.quizizz-option-wrapper > button.option {
display: flex;
flex-direction: column;
flex-grow: 1; /* CRITICAL: Button takes all available vertical space in wrapper */
min-height: 0;
width: 100%;
}
.quizizz-ddg-link-option-item {
width: 100%;
box-sizing: border-box;
margin-top: 6px;
padding: 6px 0;
border-radius: 0 0 4px 4px;
flex-shrink: 0;
}
/* --- End Option Button Styling --- */
.quizizz-ddg-link-main-question, .quizizz-gemini-button-main-question {
display: block; width: fit-content; margin: 8px auto 0 auto;
}
/* Gemini Response Popup - Dark Mode (Unchanged) */
.quizizz-gemini-response-popup {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background-color: #2d3748; color: #e2e8f0; border: 1px solid #4a5568;
border-radius: 8px; padding: 20px; z-index: 10001; min-width: 380px;
max-width: 650px; max-height: 80vh; overflow-y: auto;
box-shadow: 0 10px 25px rgba(0,0,0,0.35), 0 6px 10px rgba(0,0,0,0.25);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
font-size: 15px;
}
.quizizz-gemini-response-popup-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #4a5568;
}
.quizizz-gemini-response-popup-title { font-weight: 600; font-size: 1.2em; color: #a0aec0; }
.quizizz-gemini-response-popup-close {
background: none; border: none; font-size: 1.9em; line-height: 1;
cursor: pointer; color: #a0aec0; padding: 0 5px; transition: color 0.2s ease-in-out;
}
.quizizz-gemini-response-popup-close:hover { color: #cbd5e0; }
.quizizz-gemini-response-popup-content {
white-space: pre-wrap; font-size: 1em; line-height: 1.65; color: #cbd5e0;
}
.quizizz-gemini-response-popup-content strong,
.quizizz-gemini-response-popup-content b { color: #e2e8f0; font-weight: 600; }
.quizizz-gemini-response-popup-loading {
text-align: center; font-style: italic; color: #a0aec0; padding: 25px 0; font-size: 1.05em;
}
.quizizz-gemini-response-popup::-webkit-scrollbar { width: 8px; }
.quizizz-gemini-response-popup::-webkit-scrollbar-track { background: #2d3748; }
.quizizz-gemini-response-popup::-webkit-scrollbar-thumb {
background-color: #4a5568; border-radius: 4px; border: 2px solid #2d3748;
}
.quizizz-gemini-response-popup::-webkit-scrollbar-thumb:hover { background-color: #718096; }
`);
let currentQuestionImageUrl = null;
GM_registerMenuCommand('Set Gemini API Key', () => {
const currentKey = GM_getValue(GEMINI_API_KEY_STORAGE, '');
const newKey = prompt('Enter your Gemini API Key:', currentKey);
if (newKey !== null) {
GM_setValue(GEMINI_API_KEY_STORAGE, newKey.trim());
GM_log('Gemini API Key updated.');
alert('Gemini API Key saved!');
}
});
async function getApiKey() {
let apiKey = GM_getValue(GEMINI_API_KEY_STORAGE, null);
if (!apiKey || apiKey.trim() === '') {
apiKey = prompt('Gemini API Key not set. Please enter your API Key:');
if (apiKey && apiKey.trim() !== '') {
GM_setValue(GEMINI_API_KEY_STORAGE, apiKey.trim());
return apiKey.trim();
} else {
alert('Gemini API Key is required. Set it via the Tampermonkey menu.');
return null;
}
}
return apiKey.trim();
}
function fetchImageAsBase64(imageUrl) {
return new Promise((resolve, reject) => {
GM_log(`Fetching image: ${imageUrl}`);
GM_xmlhttpRequest({
method: 'GET',
url: imageUrl,
responseType: 'blob',
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const blob = response.response;
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result;
const mimeType = dataUrl.substring(dataUrl.indexOf(':') + 1, dataUrl.indexOf(';'));
const base64Data = dataUrl.substring(dataUrl.indexOf(',') + 1);
GM_log(`Image fetched successfully. MIME type: ${mimeType}, Size: ~${(base64Data.length * 0.75 / 1024).toFixed(2)} KB`);
resolve({ base64Data, mimeType });
};
reader.onerror = (error) => {
GM_log('FileReader error: ' + error);
reject('FileReader error while processing image.');
};
reader.readAsDataURL(blob);
} else {
GM_log(`Failed to fetch image. Status: ${response.status}`);
reject(`Failed to fetch image. Status: ${response.status}`);
}
},
onerror: function(error) {
GM_log('GM_xmlhttpRequest error fetching image: ' + JSON.stringify(error));
reject('Network error while fetching image.');
},
ontimeout: function() {
GM_log('Image fetch request timed out.');
reject('Image fetch request timed out.');
}
});
});
}
function askGemini(apiKey, question, options, imageData) {
let promptText = `
Context: You are an AI assistant helping a user with a Quizizz quiz.
The user needs to identify the correct answer(s) from the given options for the following question.
${imageData ? "An image is associated with this question; please consider it in your analysis." : ""}
Question: "${question}"
Available Options:
${options.map((opt, i) => `${i + 1}. ${opt}`).join('\n')}
Please perform the following:
1. Identify the correct answer or answers from the "Available Options" list.
2. Provide a concise reasoning for your choice(s).
3. Format your response clearly. Start with "Correct Answer(s):" followed by the answer(s) (you can refer to them by option number or text), and then "Reasoning:" followed by your explanation. Be brief and to the point. If the options seem unrelated or the question cannot be answered with the provided information (including the image if present), please state that clearly in your reasoning.
`;
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${apiKey}`;
let requestPayloadContents = [{ parts: [{ text: promptText }] }];
if (imageData && imageData.base64Data && imageData.mimeType) {
requestPayloadContents[0].parts.push({
inline_data: {
mime_type: imageData.mimeType,
data: imageData.base64Data
}
});
}
const apiPayload = {
contents: requestPayloadContents,
generationConfig: {
temperature: 0.2, maxOutputTokens: 500, topP: 0.95, topK: 40
},
safetySettings: [
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }
]
};
GM_log('--- Sending to Gemini API ---');
// GM_log('Request URL: ' + apiUrl); // Can be verbose
// GM_log('Request Payload: ' + JSON.stringify(apiPayload, null, 2)); // Can be very verbose
showGeminiResponsePopup("Loading Gemini's insights...", true);
GM_xmlhttpRequest({
method: 'POST', url: apiUrl, headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(apiPayload),
onload: function(response) {
GM_log('--- Received from Gemini API ---');
GM_log('Response Status: ' + response.status);
// GM_log('Raw Response Text: ' + response.responseText); // Can be verbose
try {
const result = JSON.parse(response.responseText);
if (result.candidates && result.candidates.length > 0 &&
result.candidates[0].content && result.candidates[0].content.parts &&
result.candidates[0].content.parts.length > 0) {
const geminiText = result.candidates[0].content.parts[0].text;
showGeminiResponsePopup(geminiText);
} else if (result.promptFeedback && result.promptFeedback.blockReason) {
showGeminiResponsePopup(`Gemini API Error: Blocked due to ${result.promptFeedback.blockReason}.\nDetails: ${JSON.stringify(result.promptFeedback.safetyRatings)}`);
} else if (result.error) {
showGeminiResponsePopup(`Gemini API Error: ${result.error.message}\nDetails: ${JSON.stringify(result.error.details)}`);
} else {
showGeminiResponsePopup('Gemini API Error: Could not parse a valid response.');
}
} catch (e) {
showGeminiResponsePopup('Gemini API Error: Failed to parse JSON response.\n' + e.message + '\nRaw response: ' + response.responseText);
}
},
onerror: function(response) {
GM_log('--- Gemini API Error (onerror) ---');
GM_log('Response Status: ' + response.status);
GM_log('Response Text: ' + response.responseText);
showGeminiResponsePopup(`Gemini API Error: Request failed. Status: ${response.status}`);
},
ontimeout: function() {
GM_log('--- Gemini API Error (ontimeout) ---');
showGeminiResponsePopup('Gemini API Error: Request timed out.');
}
});
}
function showGeminiResponsePopup(content, isLoading = false) {
let popup = document.getElementById('quizizz-gemini-popup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'quizizz-gemini-popup';
popup.classList.add('quizizz-gemini-response-popup');
const header = document.createElement('div');
header.classList.add('quizizz-gemini-response-popup-header');
const title = document.createElement('span');
title.classList.add('quizizz-gemini-response-popup-title');
title.textContent = "Gemini AI Response";
const closeButton = document.createElement('button');
closeButton.classList.add('quizizz-gemini-response-popup-close');
closeButton.innerHTML = '×';
closeButton.onclick = () => popup.remove();
header.appendChild(title);
header.appendChild(closeButton);
popup.appendChild(header);
const contentDiv = document.createElement('div');
contentDiv.classList.add('quizizz-gemini-response-popup-content');
popup.appendChild(contentDiv);
document.body.appendChild(popup);
}
const contentDiv = popup.querySelector('.quizizz-gemini-response-popup-content');
if (isLoading) {
contentDiv.innerHTML = `<div class="quizizz-gemini-response-popup-loading">${content}</div>`;
} else {
let formattedContent = content.replace(/^(Correct Answer\(s\):)/gmi, '<strong>$1</strong>');
formattedContent = formattedContent.replace(/^(Reasoning:)/gmi, '<br><br><strong>$1</strong>');
contentDiv.innerHTML = formattedContent;
}
popup.style.display = 'block';
}
function extractAndProcess() {
// console.clear(); // Optional: uncomment if you prefer the console to be cleared on each run
GM_log('Quizizz Hypertool: Running extraction (v1.3.1 based on page info change)...');
document.querySelectorAll('.quizizz-ddg-link-main-question, .quizizz-gemini-button-main-question').forEach(link => link.remove());
document.querySelectorAll('.quizizz-option-wrapper').forEach(wrapper => {
const button = wrapper.querySelector('button.option');
const parent = wrapper.parentNode;
if (button && parent) {
parent.insertBefore(button, wrapper); // Move button out of wrapper
}
wrapper.remove(); // Remove wrapper
});
let questionTitle = '';
let optionTexts = [];
currentQuestionImageUrl = null; // Reset for current question
const questionImageElement = document.querySelector(
'div[data-testid="question-container-text"] img, ' +
'div[class*="question-media-container"] img, ' +
'img[data-testid="question-container-image"], ' +
'.question-image'
);
if (questionImageElement && questionImageElement.src) {
currentQuestionImageUrl = questionImageElement.src;
if (currentQuestionImageUrl.startsWith('/')) { // Handle relative URLs
currentQuestionImageUrl = window.location.origin + currentQuestionImageUrl;
}
GM_log('Found question image URL: ' + currentQuestionImageUrl);
} else {
GM_log('No question image found or image src is missing.');
}
const questionTitleTextElement = document.querySelector(
'div[data-testid="question-container-text"] div#questionText p, div[data-cy="question-text-color"] p'
);
const questionTextOuterContainer = document.querySelector('div[data-testid="question-container-text"]');
if (questionTitleTextElement && questionTextOuterContainer) {
questionTitle = questionTitleTextElement.textContent.trim();
GM_log('Question Title: ' + questionTitle);
if (questionTitle || currentQuestionImageUrl) { // Add buttons if there's text or image
const ddgQLink = document.createElement('a');
const ddgQuery = questionTitle ? questionTitle : (currentQuestionImageUrl ? "image in question" : "Quizizz Question");
ddgQLink.href = `https://duckduckgo.com/?q=${encodeURIComponent(ddgQuery)}`;
ddgQLink.textContent = 'DDG Q';
ddgQLink.target = '_blank';
ddgQLink.rel = 'noopener noreferrer';
ddgQLink.classList.add('quizizz-ddg-link', 'quizizz-ddg-link-main-question');
questionTextOuterContainer.appendChild(ddgQLink);
const geminiButton = document.createElement('button');
geminiButton.textContent = 'Ask Gemini';
geminiButton.classList.add('quizizz-gemini-button', 'quizizz-gemini-button-main-question');
geminiButton.onclick = async () => {
const apiKey = await getApiKey();
if (!apiKey) { GM_log("Gemini: API key missing."); return; }
if (!questionTitle && !currentQuestionImageUrl && optionTexts.length === 0) {
alert("Could not find question text, image, or options to send to Gemini.");
GM_log("Gemini: no content found."); return;
}
showGeminiResponsePopup("Preparing data for Gemini...", true);
let imageData = null;
if (currentQuestionImageUrl) {
try {
imageData = await fetchImageAsBase64(currentQuestionImageUrl);
} catch (error) {
GM_log('Error fetching image for Gemini: ' + error);
showGeminiResponsePopup(`Failed to fetch image: ${error}\nProceeding with text only.`, false);
}
}
askGemini(apiKey, questionTitle || (currentQuestionImageUrl ? "(See attached image)" : "No text question"), optionTexts, imageData);
};
questionTextOuterContainer.appendChild(geminiButton);
}
} else {
GM_log('Question Title or its container not found.');
}
// Page info is now used by the polling mechanism, but can still be logged here if desired
// const pageInfoElement = document.querySelector('div.pill p, div[class*="question-counter"] p');
// if (pageInfoElement) GM_log('Page Info (during extraction): ' + pageInfoElement.textContent.trim());
// else GM_log('Page Info element not found (during extraction).');
optionTexts = []; // Reset for current question
const optionButtons = document.querySelectorAll('button.option');
// const optionsGrid = optionButtons.length > 0 ? optionButtons[0].closest('div.options-grid, div[class*="options-layout"]') : null;
// No need to check for optionsGrid explicitly here if we iterate optionButtons
if (optionButtons.length > 0) {
Array.from(optionButtons).forEach((button) => {
const optionTextElement = button.querySelector('div#optionText p, .option-text p, .resizeable p');
if (optionTextElement) {
const optionText = optionTextElement.textContent.trim();
if (optionText) optionTexts.push(optionText);
if (optionText) { // Only add DDG link if there's text
const ddgLink = document.createElement('a');
ddgLink.href = `https://duckduckgo.com/?q=${encodeURIComponent(optionText)}`;
ddgLink.textContent = 'DDG';
ddgLink.target = '_blank';
ddgLink.rel = 'noopener noreferrer';
ddgLink.classList.add('quizizz-ddg-link', 'quizizz-ddg-link-option-item');
const wrapper = document.createElement('div');
wrapper.classList.add('quizizz-option-wrapper');
wrapper.style.flexBasis = button.style.flexBasis || '100%'; // Copy flex basis
if (button.parentNode) {
button.parentNode.insertBefore(wrapper, button);
wrapper.appendChild(button); // Move original button INTO the wrapper
wrapper.appendChild(ddgLink);
}
}
}
});
GM_log(`Processed ${optionButtons.length} option buttons. Options: ${optionTexts.join('; ')}`);
} else {
GM_log('No option buttons found.');
}
GM_log('--- Extraction complete ---');
}
// --- New logic for triggering extractAndProcess based on Page Info changes ---
let lastPageInfo = "INITIAL_STATE_PAGE_INFO_MAGIC_STRING_FOR_FIRST_RUN_DETECTION"; // Unique string unlikely to be real page info
function checkPageInfoAndReprocess() {
const pageInfoElement = document.querySelector('div.pill p, div[class*="question-counter"] p');
let currentPageInfoText = ""; // Default to empty if element not found or has no text
if (pageInfoElement) {
currentPageInfoText = pageInfoElement.textContent.trim();
}
// For debugging, uncomment the next line to see polling status:
// GM_log(`Polling. Current Page Info: "${currentPageInfoText}", Last Known: "${lastPageInfo}"`);
if (currentPageInfoText !== lastPageInfo) {
// This special condition handles the very first time the interval runs after script load.
// If the page info element isn't available yet (currentPageInfoText is ""),
// AND lastPageInfo is still the initial magic string,
// then we update lastPageInfo to "" and wait for the element to actually appear with content.
// This prevents a premature run of extractAndProcess on an empty or not-yet-fully-loaded page.
if (currentPageInfoText === "" && lastPageInfo === "INITIAL_STATE_PAGE_INFO_MAGIC_STRING_FOR_FIRST_RUN_DETECTION") {
GM_log('Initial poll: Page info element not yet found or is empty. Setting baseline and waiting for content to appear.');
lastPageInfo = currentPageInfoText; // lastPageInfo becomes ""
return; // Don't run extractAndProcess yet
}
GM_log(`Page info change detected. Old: "${lastPageInfo}", New: "${currentPageInfoText}". Triggering UI update.`);
lastPageInfo = currentPageInfoText;
extractAndProcess(); // Call the main processing function
}
}
// Start polling for page info changes.
// The first actual processing will occur when page info appears and differs from the initial magic string,
// or from "" if the element was initially not found.
setInterval(checkPageInfoAndReprocess, 1500); // Poll every 1.5 seconds
// Log script load. Version in log should match @version.
GM_log('Quizizz Hypertool (v1.3.1 - Page Info Polling) loaded. Monitoring page info for changes.');
})();