Testportal Multitool Extended

Enhanced Testportal tool with AI analysis (Gemini) and DuckDuckGo search integration.

目前為 2025-05-08 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Testportal Multitool Extended
// @namespace    https://*.testportal.pl/
// @version      3.0.5+1
// @description  Enhanced Testportal tool with AI analysis (Gemini) and DuckDuckGo search integration.
// @author       Czarek Nakamoto (mrcyjanek.net), Modified by Nyxiereal
// @license      GPL-3.0
// @match        https://*.testportal.net/*
// @match        https://*.testportal.pl/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      generativelanguage.googleapis.com
// @connect      * // Allows GM_xmlhttpRequest to fetch images from any domain Testportal might use. Be cautious with this.
// ==/UserScript==

(function() {
    'use strict';
    console.log("[TESTPORTAL MULTITOOL ADVANCED AI + DDG] started");

    // --- Constants ---
    const GEMINI_API_KEY_STORAGE = "testportalMultiToolGeminiApiKey_v2";
    const GEMINI_MODEL = 'gemini-2.0-flash'; // Stable and efficient model
    const GEMINI_API_URL_BASE = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=`;

    // --- Original RegExp Override ---
    const originalRegExpTest = RegExp.prototype.test;
    RegExp.prototype.test = function (s) {
        const string = this.toString();
        if (string.includes("native code") && string.includes("function")) {
            return true;
        }
        return originalRegExpTest.call(this, s);
    };

    // --- Styles for Popup and Buttons ---
    GM_addStyle(`
        .tp-gemini-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; width: 90%; 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;
        }
        .tp-gemini-popup-header {
            display: flex; justify-content: space-between; align-items: center;
            margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #4a5568;
        }
        .tp-gemini-popup-title { font-weight: 600; font-size: 1.2em; color: #a0aec0; }
        .tp-gemini-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;
        }
        .tp-gemini-popup-close:hover { color: #cbd5e0; }
        .tp-gemini-popup-content {
            white-space: pre-wrap; font-size: 1em; line-height: 1.65; color: #cbd5e0;
        }
        .tp-gemini-popup-content strong,
        .tp-gemini-popup-content b { color: #e2e8f0; font-weight: 600; }
        .tp-gemini-popup-loading {
            text-align: center; font-style: italic; color: #a0aec0; padding: 25px 0; font-size: 1.05em;
        }
        .tp-gemini-popup::-webkit-scrollbar { width: 8px; }
        .tp-gemini-popup::-webkit-scrollbar-track { background: #2d3748; }
        .tp-gemini-popup::-webkit-scrollbar-thumb {
            background-color: #4a5568; border-radius: 4px; border: 2px solid #2d3748;
        }
        .tp-gemini-popup::-webkit-scrollbar-thumb:hover { background-color: #718096; }
        .tp-ai-button {
            background-color: #007bff; color: white; border: none; padding: 6px 10px;
            margin-left: 10px; border-radius: 4px; cursor: pointer; font-size: 0.9em;
            vertical-align: middle;
        }
        .tp-ai-button:hover { background-color: #0056b3; }

        .tp-ddg-button {
            background-color: #de5833; /* DuckDuckGo Orange */
            color: white;
            border: none;
            padding: 6px 10px;
            margin-left: 8px; /* Space from the AI button or other elements */
            border-radius: 4px;
            cursor: pointer;
            font-size: 0.9em;
            vertical-align: middle; /* Align with AI button */
        }
        .tp-ddg-button:hover {
            background-color: #b94929; /* Darker orange on hover */
        }
    `);

    // --- API Key Management ---
    function getGeminiApiKey() {
        let apiKey = GM_getValue(GEMINI_API_KEY_STORAGE, null);
        if (!apiKey || apiKey.trim() === "") {
            apiKey = prompt("Please enter your Google Gemini API Key (e.g., AIzaSy...). This will be stored locally for this script.");
            if (apiKey && apiKey.trim() !== "") {
                GM_setValue(GEMINI_API_KEY_STORAGE, apiKey.trim());
            } else {
                alert("Gemini API Key not provided. AI features will be disabled for this session.");
                return null;
            }
        }
        return apiKey.trim();
    }

    GM_registerMenuCommand('Set/Update Gemini API Key', () => {
        const currentKey = GM_getValue(GEMINI_API_KEY_STORAGE, '');
        const newKey = prompt('Enter/Update your Gemini API Key:', currentKey);
        if (newKey !== null) { // User didn't cancel
            GM_setValue(GEMINI_API_KEY_STORAGE, newKey.trim());
            alert('Gemini API Key ' + (newKey.trim() ? 'saved!' : 'cleared!'));
        }
    });

    function clearGeminiApiKey() { // For debugging or user reset
        GM_deleteValue(GEMINI_API_KEY_STORAGE);
        console.log("Gemini API Key cleared from local storage.");
        alert("Stored Gemini API Key has been cleared.");
    }
    // GM_registerMenuCommand('Clear Gemini API Key', clearGeminiApiKey); // Optionally add to menu

    // --- Image Fetching ---
    function fetchImageAsBase64(imageUrl) {
        return new Promise((resolve, reject) => {
            console.log(`[AI Helper] 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);
                            console.log(`[AI Helper] Image fetched. MIME: ${mimeType}, Size: ~${(base64Data.length * 0.75 / 1024).toFixed(2)} KB`);
                            resolve({ base64Data, mimeType });
                        };
                        reader.onerror = (error) => reject('FileReader error: ' + error);
                        reader.readAsDataURL(blob);
                    } else {
                        reject(`Failed to fetch image. Status: ${response.status}`);
                    }
                },
                onerror: (error) => reject('Network error fetching image: ' + JSON.stringify(error)),
                ontimeout: () => reject('Image fetch request timed out.')
            });
        });
    }

    // --- Gemini API Interaction ---
    async function queryGeminiWithDetails(apiKey, questionText, options, imageData = null) {
        showGeminiPopup("⏳ Analyzing with Gemini AI...", true);

        let prompt = `
Context: You are an AI assistant helping a user with a Testportal online test.
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 carefully in your analysis." : ""}

Question: "${questionText}"

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 question is ambiguous or cannot be answered with the provided information (including the image if present), please state that clearly in your reasoning.
        `;

        const apiUrl = `${GEMINI_API_URL_BASE}${apiKey}`;
        let requestPayloadContents = [{ parts: [{ text: prompt.trim() }] }];

        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: 1024, topP: 0.95, topK: 40 },
            safetySettings: [ /* Standard safety settings */
                { 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_xmlhttpRequest({
            method: 'POST',
            url: apiUrl,
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify(apiPayload),
            timeout: 60000, // 60 seconds timeout
            onload: function(response) {
                try {
                    const result = JSON.parse(response.responseText);
                    if (result.candidates && result.candidates[0]?.content?.parts?.[0]?.text) {
                        showGeminiPopup(result.candidates[0].content.parts[0].text);
                    } else if (result.promptFeedback?.blockReason) {
                        showGeminiPopup(`Gemini API Error: Blocked - ${result.promptFeedback.blockReason}.\nDetails: ${JSON.stringify(result.promptFeedback.safetyRatings)}`);
                    } else if (result.error) {
                        showGeminiPopup(`Gemini API Error: ${result.error.message}\nDetails: ${JSON.stringify(result.error.details)}`);
                    } else {
                        console.error("[AI Helper] Gemini unexpected response:", result);
                        showGeminiPopup('Gemini API Error: Could not parse a valid response text.');
                    }
                } catch (e) {
                    console.error("[AI Helper] Gemini response parse error:", e, response.responseText);
                    showGeminiPopup(`Gemini API Error: Failed to parse JSON response. ${e.message}`);
                }
            },
            onerror: (err) => {
                console.error("[AI Helper] Gemini request error:", err);
                showGeminiPopup(`Gemini API Network Error. Status: ${err.status}. Check console.`);
            },
            ontimeout: () => {
                console.error("[AI Helper] Gemini request timeout.");
                showGeminiPopup('Gemini API Error: Request timed out.');
            }
        });
    }

    // --- Popup Display ---
    function showGeminiPopup(content, isLoading = false) {
        let popup = document.getElementById('tp-gemini-ai-popup');
        if (!popup) {
            popup = document.createElement('div');
            popup.id = 'tp-gemini-ai-popup';
            popup.classList.add('tp-gemini-popup');
            popup.innerHTML = `
                <div class="tp-gemini-popup-header">
                    <span class="tp-gemini-popup-title">Gemini AI Helper</span>
                    <button class="tp-gemini-popup-close" title="Close">×</button>
                </div>
                <div class="tp-gemini-popup-content"></div>
            `;
            document.body.appendChild(popup);
            popup.querySelector('.tp-gemini-popup-close').onclick = () => popup.remove();
        }
        const contentDiv = popup.querySelector('.tp-gemini-popup-content');
        if (isLoading) {
            contentDiv.innerHTML = `<div class="tp-gemini-popup-loading">${content}</div>`;
        } else {
            let formattedContent = content
                .replace(/^(Correct Answer\(s\):)/gmi, '<strong>$1</strong>')
                .replace(/^(Reasoning:)/gmi, '<br><br><strong>$1</strong>');
            contentDiv.innerHTML = formattedContent;
        }
        popup.style.display = 'block';
    }


    function bypassTimeLimit() {
        window.startTime = Infinity;
        document.hasFocus = () => true;

        const remainingTimeContent = document.getElementById("remaining_time_content");
        if (remainingTimeContent) remainingTimeContent.outerHTML = "";

        const remainingTimeLabel = document.getElementById("remaining_time_label");
        if (remainingTimeLabel && !remainingTimeLabel.dataset.timeBypassed) {
            remainingTimeLabel.style.color = "#0bc279";
            remainingTimeLabel.style.fontWeight = "600";
            remainingTimeLabel.innerText = "Czas: Nieograniczony (MultiTool)";
            remainingTimeLabel.dataset.timeBypassed = "true";
        }
    }

    async function handleAiButtonClick(questionText, options, imageUrl) {
        const apiKey = getGeminiApiKey();
        if (!apiKey) return;

        let imageData = null;
        if (imageUrl) {
            try {
                showGeminiPopup("⏳ Fetching image...", true);
                if (imageUrl.startsWith('/')) {
                    imageUrl = window.location.origin + imageUrl;
                }
                imageData = await fetchImageAsBase64(imageUrl);
            } catch (error) {
                console.error("[AI Helper] Error fetching image:", error);
                showGeminiPopup(`⚠️ Error fetching image: ${error}.\nProceeding with text only.`, false);
                await new Promise(resolve => setTimeout(resolve, 2000));
            }
        }
        queryGeminiWithDetails(apiKey, questionText, options, imageData);
    }

    function handleDuckDuckGoButtonClick(questionText) {
        if (!questionText) return;
        const searchUrl = `https://duckduckgo.com/?q=${encodeURIComponent(questionText)}`;
        window.open(searchUrl, '_blank').focus();
    }

    function enhanceQuestions() {
        const questionEssenceElements = document.getElementsByClassName("question_essence");

        for (const qEssenceEl of questionEssenceElements) {
            if (qEssenceEl.dataset.enhancementsAdded) continue; // Use a more generic flag

            let questionTextContent = qEssenceEl.innerText || qEssenceEl.textContent;
            questionTextContent = questionTextContent.trim();

            if (!questionTextContent) continue;

            // Find associated answer options.
            const answerElements = document.getElementsByClassName("answer_body");
            const options = Array.from(answerElements)
                                 .map(optEl => (optEl.innerText || optEl.textContent).trim())
                                 .filter(Boolean);

            // Find an image within or very near the question_essence.
            let questionImageElement = qEssenceEl.querySelector('img');
            if (!questionImageElement) {
                const commonParent = qEssenceEl.closest('.question_container_wrapper, .question-view, .question-content, form, div.row');
                if (commonParent) {
                    questionImageElement = commonParent.querySelector('img.question-image, img.question_image_preview, .question_media img, .question-body__attachment img');
                }
            }
            const imageUrl = questionImageElement ? questionImageElement.src : null;

            // AI Button
            const aiButton = document.createElement('button');
            aiButton.textContent = "🧠 Ask AI";
            aiButton.title = "Analyze question, options, and image with Gemini AI";
            aiButton.classList.add('tp-ai-button');
            aiButton.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                handleAiButtonClick(questionTextContent, options, imageUrl);
            };
            qEssenceEl.appendChild(aiButton);

            // DuckDuckGo Button
            const ddgButton = document.createElement('button');
            ddgButton.textContent = "🔎 DDG";
            ddgButton.title = "Search this question on DuckDuckGo";
            ddgButton.classList.add('tp-ddg-button');
            ddgButton.onclick = (e) => {
                e.preventDefault();
                e.stopPropagation();
                handleDuckDuckGoButtonClick(questionTextContent);
            };
            qEssenceEl.appendChild(ddgButton);

            qEssenceEl.dataset.enhancementsAdded = "true";
            console.log("[MultiTool] Added AI and DDG buttons for question:", questionTextContent.substring(0, 50) + "...");
        }
    }

    // --- Script Execution Logic ---
    function initPageMessage() {
        console.log("Testportal MultiTool active on LoadTestStart page.");
        // Any specific logic for LoadTestStart.html can go here.
        // For now, just acknowledging it.
    }


    if (window.location.href.includes("LoadTestStart.html")) {
        setTimeout(initPageMessage, 200); // Delay to ensure page elements are loaded
    } else {
        // For active test pages
        const observer = new MutationObserver((mutationsList, obs) => {
            bypassTimeLimit();
            enhanceQuestions();
        });

        observer.observe(document.body, { childList: true, subtree: true });

        // Initial run for content already present
        setTimeout(() => {
            bypassTimeLimit();
            enhanceQuestions();
        }, 100); // Initial small delay
    }

})();