Bunpro study toolkit — audio replay/download, conjugation lookup, and Gemini AI explanation for grammar review sessions.
// ==UserScript==
// @name Bunpro - Toolkit
// @namespace https://greasyfork.org/
// @version 2.1.2
// @description Bunpro study toolkit — audio replay/download, conjugation lookup, and Gemini AI explanation for grammar review sessions.
// @author VictorKndy
// @match https://bunpro.jp/*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
let lastKnownUrl = "";
const BTN_SIZE = 36; // px – compact toolbar buttons
const BTN_GAP = 5; // px – gap between buttons
let toolbarExpanded = true; // toolbar visible by default
// --- Gemini AI Configuration ---
const GEMINI_API_KEY = "";
const GEMINI_MODEL = "gemini-3-flash-preview";
const GEMINI_URI = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
// --- Gemini API call ---
async function callGemini(prompt) {
const response = await fetch(GEMINI_URI, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.9,
thinkingConfig: { thinkingLevel: "MEDIUM", includeThoughts: false }
}
})
});
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`);
}
const data = await response.json();
const parts = data.candidates?.[0]?.content?.parts ?? [];
return parts
.filter(p => p.text)
.map(p => p.text)
.join('')
.replaceAll('*', '');
}
// --- AI explanation display helpers ---
function resetExplanation() {
let el = document.getElementById("gm-ai-explanation");
if (!el) {
el = document.createElement('div');
el.id = "gm-ai-explanation";
el.style.cssText = "margin-top: 10px; font-size: 14px; line-height: 1.5; color: #ddd;";
const quizEl = document.getElementById('js-tour-quiz-question');
if (quizEl) quizEl.appendChild(el);
}
el.innerText = "";
el.style.fontStyle = "italic";
// Remove any existing clear button
const oldClear = document.getElementById("gm-ai-clear-btn");
if (oldClear) oldClear.remove();
}
function addClearButton() {
const el = document.getElementById("gm-ai-explanation");
if (!el) return;
const clearBtn = document.createElement('button');
clearBtn.id = "gm-ai-clear-btn";
clearBtn.innerHTML = '✕';
clearBtn.title = 'Clear explanation';
clearBtn.style.cssText = `
margin-left: 8px; padding: 2px 8px; background: #555; color: #fff;
border: none; border-radius: 4px; cursor: pointer; font-size: 13px;
vertical-align: middle;
`;
clearBtn.onmousedown = (e) => e.preventDefault(); // prevent stealing focus
clearBtn.onclick = (e) => {
e.preventDefault();
el.innerText = "";
clearBtn.remove();
};
el.appendChild(clearBtn);
}
// --- SVG Icons (inline, no external deps) ---
const ICONS = {
// Hamburger / toggle
menu: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
// Speaker – play audio
play: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`,
// Download
download: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
// Open book – conjugation
book: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
// Brain – AI
brain: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a5 5 0 0 1 5 5c0 1.1-.4 2.1-1 2.9l.2.1a4 4 0 0 1 1.8 3.4 4 4 0 0 1-2.3 3.6c.2.5.3 1 .3 1.5a3.5 3.5 0 0 1-4 3.5 3.5 3.5 0 0 1-4-3.5c0-.5.1-1 .3-1.5A4 4 0 0 1 6 13.4a4 4 0 0 1 1.8-3.4l.2-.1A5 5 0 0 1 7 7a5 5 0 0 1 5-5z"/><path d="M12 2v20"/></svg>`,
};
// --- Shared button style ---
const baseBtnStyle = `
width: ${BTN_SIZE}px; height: ${BTN_SIZE}px;
display: flex; align-items: center; justify-content: center;
border: none; border-radius: 8px;
cursor: pointer; padding: 0;
font-family: sans-serif; transition: all 0.2s;
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
color: #fff;
`;
// --- Helper: create icon button ---
function makeBtn(id, svgHtml, bg, title) {
const btn = document.createElement('button');
btn.id = id;
btn.innerHTML = svgHtml;
btn.title = title;
btn.style.cssText = baseBtnStyle + `background: ${bg};`;
return btn;
}
// --- UI Setup ---
const setupUI = () => {
if (document.getElementById("gm-toolkit-container")) return;
// Container – top-left, vertical column, positioned below Bunpro native buttons
const container = document.createElement('div');
container.id = 'gm-toolkit-container';
container.style.cssText = `
position: fixed; top: 50px; left: 8px; z-index: 999999;
display: flex; flex-direction: row; gap: ${BTN_GAP}px;
align-items: center;
`;
document.body.appendChild(container);
// --- 1. Toggle button (always visible) ---
const toggleBtn = makeBtn('gm-toggle-btn', ICONS.menu, '#555', 'Show / Hide toolkit');
container.appendChild(toggleBtn);
// --- Collapsible group ---
const toolGroup = document.createElement('div');
toolGroup.id = 'gm-tool-group';
toolGroup.style.cssText = `
display: flex; flex-direction: row; gap: ${BTN_GAP}px;
transition: opacity 0.2s, max-width 0.3s;
overflow: hidden;
align-items: center;
`;
container.appendChild(toolGroup);
// --- 2. Play audio button ---
const playBtn = makeBtn('gm-play-btn', ICONS.play, '#333', 'Play sentence audio');
playBtn.style.opacity = '0.4';
playBtn.style.cursor = 'not-allowed';
toolGroup.appendChild(playBtn);
// --- 3. Download button ---
const dlBtn = makeBtn('gm-download-btn', ICONS.download, '#333', 'Download MP3');
dlBtn.style.opacity = '0.4';
dlBtn.style.cursor = 'not-allowed';
toolGroup.appendChild(dlBtn);
// --- 4. Conjugation lookup button ---
const conjBtn = makeBtn('gm-conj-btn', ICONS.book, '#4A148C', 'Conjugation lookup');
toolGroup.appendChild(conjBtn);
// --- 5. AI button ---
const aiBtn = makeBtn('gm-ai-btn', ICONS.brain, '#0D47A1', 'AI assistant');
toolGroup.appendChild(aiBtn);
// --- Toggle handler ---
toggleBtn.onclick = () => {
toolbarExpanded = !toolbarExpanded;
toolGroup.style.display = toolbarExpanded ? 'flex' : 'none';
toggleBtn.innerHTML = toolbarExpanded ? ICONS.close : ICONS.menu;
toggleBtn.title = toolbarExpanded ? 'Hide toolkit' : 'Show toolkit';
};
// --- Play handler ---
playBtn.onclick = () => {
if (lastKnownUrl) {
const a = new Audio(lastKnownUrl);
a.play().catch(err => {
console.error("Playback failed:", err);
playBtn.title = 'Error playing audio';
});
}
};
// --- Download handler ---
dlBtn.onclick = () => {
if (lastKnownUrl) {
const link = document.createElement('a');
link.href = lastKnownUrl;
link.setAttribute('download', lastKnownUrl.split('/').pop());
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
// --- Conjugation lookup – open IMABI search in new tab ---
conjBtn.onclick = () => {
const sentenceElement = document.querySelector('.bp-quiz-question.relative > div > button');
const answerOutput = sentenceElement ? sentenceElement.innerText.trim() : "";
if (answerOutput) {
navigator.clipboard.writeText(answerOutput).catch(err => console.warn('[Toolkit] Clipboard write failed:', err));
window.open('https://imabi.org/?s=' + encodeURIComponent(answerOutput), '_blank', 'noopener');
} else {
console.log('[Toolkit] No answer output found to look up');
}
};
// --- AI explain handler ---
aiBtn.onmousedown = (e) => e.preventDefault(); // keep focus behaviour under our control
aiBtn.onclick = () => {
// Blur the input so virtual keyboard hides on mobile/tablet
const inputEl = document.getElementById('js-manual-input');
if (inputEl) inputEl.blur();
document.activeElement?.blur();
if (!document.querySelector('.bp-quiz-console--incorrect')) {
console.log('[Toolkit] No incorrect answer detected – nothing to explain');
return;
}
resetExplanation();
const explEl = document.getElementById("gm-ai-explanation");
if (explEl) explEl.innerText = "Loading explanation...";
try {
const sentenceElement = document.querySelector('.bp-quiz-question.relative > div > button');
const answerSentence = sentenceElement ? sentenceElement.parentNode.innerText.trim() : "";
const answerOutput = sentenceElement ? sentenceElement.innerText.trim() : "";
const inputElement = document.getElementById('js-manual-input');
const userInput = inputElement ? inputElement.value.trim() : "";
console.log({ answerSentence, answerOutput, userInput });
const prompt = `Explain the specific grammatical or contextual reason ${answerOutput} fits ${answerSentence} while ${userInput} does not. Provide mechanical reason rather than general definitions. Provide only as much detail as necessary; do not exceed 60 words. Write in English but use Japanese script where necessary; strictly no romaji.`;
callGemini(prompt)
.then(result => {
const el = document.getElementById("gm-ai-explanation");
if (el) {
el.style.fontStyle = "";
el.innerText = result;
addClearButton();
}
})
.catch(error => {
console.error("[Toolkit] AI error:", error);
const el = document.getElementById("gm-ai-explanation");
if (el) el.innerText = "Error: " + error.message;
});
} catch (e) {
console.error("[Toolkit] Data extraction failed:", e);
if (explEl) explEl.innerText = "Could not read question context.";
}
};
};
// --- Interception Logic ---
const originalSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
Object.defineProperty(HTMLAudioElement.prototype, 'src', {
set: function(url) {
if (url && typeof url === 'string' && url.includes('.mp3')) {
handleNewAudio(url);
}
return originalSrcDescriptor.set.apply(this, arguments);
},
get: function() {
return originalSrcDescriptor.get.apply(this);
}
});
const originalAttributeSetter = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (name === 'src' && typeof value === 'string' && value.includes('.mp3')) {
handleNewAudio(value);
}
originalAttributeSetter.apply(this, arguments);
};
function handleNewAudio(url) {
if (url === lastKnownUrl) return;
console.log("[Toolkit] New Audio Detected:", url);
lastKnownUrl = url;
const pBtn = document.getElementById("gm-play-btn");
const dBtn = document.getElementById("gm-download-btn");
if (pBtn) {
pBtn.style.background = "#1B5E20";
pBtn.style.cursor = "pointer";
pBtn.style.opacity = "1";
pBtn.title = "Play sentence audio";
}
if (dBtn) {
dBtn.style.background = "#0D47A1";
dBtn.style.cursor = "pointer";
dBtn.style.opacity = "1";
dBtn.title = "Download MP3";
}
}
const checkInterval = setInterval(() => {
if (document.body) {
setupUI();
clearInterval(checkInterval);
}
}, 100);
})();