Bunpro - Toolkit

Bunpro study toolkit — audio replay/download, conjugation lookup, and Gemini AI explanation for grammar review sessions.

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

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

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

})();