AI Page Summarizer

Summarize webpage or selected text via remote API key

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         AI Page Summarizer
// @version      1.3.4
// @description  Summarize webpage or selected text via remote API key
// @author       SH3LL
// @match        *://*/*
// @grant        GM.xmlHttpRequest
// @grant        GM.setValue
// @grant        GM.getValue
// @run-at       document-end
// @namespace    http://tampermonkey.net/
// ==/UserScript==

(function () {
    'use strict';

    // === State ===
    let API_KEYS = []; // Will be loaded from storage
    let loading = false;
    let isSidebarVisible = false;
    let isSettingsVisible = false;
    let rateLimitRemaining = 0;
    let rateLimitInterval = null;
    const browserLanguage = navigator.language;
    const selectedLanguage = new Intl.DisplayNames([browserLanguage], { type: 'language' }).of(browserLanguage) || browserLanguage;

    // === YouTube Transcript State ===
    let youtubeTranscript = null;
    let styleElement = null;

    const hideCSS = `
        ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"] {
            opacity: 0 !important;
            z-index: -9999 !important;
        }
    `;

    // === Models ===
    const models = [
        { label: 'gpt-oss-120b', model: 'gpt-oss-120b' },
        { label: 'gpt-5-nano', model: 'gpt-5-nano' },
        { label: 'gemini-2.5-pro', model: 'gemini-2.5-pro' },
        { label: 'claude-sonnet-4.6', model: 'claude-sonnet-4.6' },
    ];

    let selectedModel = models[0].model;

    // === Init UI ===
    const {
        shadowRoot, sidebar, toggleButton, summarizeButton,
        statusDisplay, summaryContainer, modelSelect, rateLimitDisplay,
        settingsButton, settingsContainer, apiKeyInput, saveKeysButton
    } = createSidebarUI();

    document.body.appendChild(shadowRoot.host);

    // === Load Keys on Start ===
    loadApiKeys();

    // === Fade timeout ===
    let hoverTimeout;
    setTimeout(() => toggleButton.style.opacity = '0.3', 1000);

    toggleButton.addEventListener('mouseover', () => {
        clearTimeout(hoverTimeout);
        toggleButton.style.opacity = '1';
    });

    toggleButton.addEventListener('mouseout', () => {
        hoverTimeout = setTimeout(() => {
            if (!isSidebarVisible) toggleButton.style.opacity = '0.3';
        }, 2000);
    });

    // === Event Listeners ===
    document.addEventListener('mouseup', updateButtonText);
    toggleButton.addEventListener('click', toggleSidebar);
    summarizeButton.addEventListener('click', handleSummarizeClick);
    settingsButton.addEventListener('click', toggleSettings);
    saveKeysButton.addEventListener('click', saveApiKeysFromInput);

    modelSelect.addEventListener('change', e => {
        selectedModel = models[e.target.value].model;
        updateStatus('Idle', '#555');
    });

    updateButtonText();
    updateStatus('Idle', '#555');

    // === Key Management Functions ===
    async function loadApiKeys() {
        const storedKeys = await GM.getValue('saved_api_keys', '');
        if (storedKeys) {
            API_KEYS = storedKeys.split(',').map(k => k.trim()).filter(k => k.length > 0);
            updateStatus(`Loaded ${API_KEYS.length} keys`, '#0c6');
            apiKeyInput.value = API_KEYS.join('\n'); // Show in settings for editing
        } else {
            updateStatus('No API Keys!', '#f55');
            // Auto open settings if no keys
            setTimeout(() => {
                if(!isSidebarVisible) toggleSidebar();
                if(!isSettingsVisible) toggleSettings();
            }, 500);
        }
    }

    async function saveApiKeysFromInput() {
        const text = apiKeyInput.value;
        // Split by newlines or commas
        const keys = text.split(/[\n,]/).map(k => k.trim()).filter(k => k.length > 5);

        if (keys.length === 0) {
            alert("Please enter at least one valid API Key.");
            return;
        }

        await GM.setValue('saved_api_keys', keys.join(','));
        API_KEYS = keys;
        updateStatus(`Saved ${keys.length} keys`, '#0c6');
        toggleSettings(); // Close settings
    }

    function getRandomApiKey() {
        if (!API_KEYS || API_KEYS.length === 0) return null;
        const randomArray = new Uint32Array(1);
        crypto.getRandomValues(randomArray);
        const randomIndex = randomArray[0] % API_KEYS.length;
        console.log(`🔑 Using API key index: ${randomIndex + 1}`);
        return API_KEYS[randomIndex];
    }

    // === YouTube Transcript Functions ===
    function hidePanel() {
        if (!styleElement) {
            styleElement = document.createElement('style');
            styleElement.textContent = hideCSS;
            document.head.appendChild(styleElement);
        }
    }

    function showPanel() {
        if (styleElement) {
            styleElement.remove();
            styleElement = null;
        }
    }

    function waitForSegments(timeout = 20000) {
        return new Promise((resolve, reject) => {
            const startTime = Date.now();
            const check = () => {
                const segments = document.querySelectorAll('ytd-transcript-segment-renderer yt-formatted-string.segment-text');
                if (segments.length > 0) {
                    resolve(segments);
                    return;
                }
                if (Date.now() - startTime > timeout) {
                    reject(new Error("Timeout: segmenti non caricati"));
                    return;
                }
                setTimeout(check, 200);
            };
            check();
        });
    }

    async function extractTranscript() {
        console.log("👻 Avvio estrazione trascrizione YouTube...");
        const moreButton = document.querySelector('tp-yt-paper-button#expand');
        if (moreButton) {
            moreButton.click();
            await new Promise(r => setTimeout(r, 500));
        }

        let transcriptBtn = null;
        const allButtons = document.querySelectorAll('button');
        for (const btn of allButtons) {
            const text = btn.textContent.toLowerCase();
            if (text.includes('trascrizione') || text.includes('transcript')) {
                transcriptBtn = btn;
                break;
            }
        }

        if (!transcriptBtn) {
            console.log("❌ Pulsante trascrizione non trovato.");
            return null;
        }

        transcriptBtn.click();
        await new Promise(r => setTimeout(r, 100));
        hidePanel();

        try {
            const segments = await waitForSegments(20000);
            let fullText = "";
            segments.forEach(el => {
                fullText += el.textContent.trim() + " ";
            });
            fullText = fullText.replace(/\s+/g, ' ').trim();

            const closeBtn = document.querySelector(
                '[target-id="engagement-panel-searchable-transcript"] #visibility-button button, ' +
                '[target-id="engagement-panel-searchable-transcript"] button[aria-label*="Chiudi"], ' +
                '[target-id="engagement-panel-searchable-transcript"] button[aria-label*="Close"]'
            );
            if (closeBtn) {
                closeBtn.click();
            }

            showPanel();
            return fullText;

        } catch (err) {
            showPanel();
            console.error("❌", err.message);
            return null;
        }
    }

    async function initYouTubeTranscript() {
        youtubeTranscript = null;
        updateButtonText();
        updateStatus('Transcript...', '#f90');

        const transcript = await extractTranscript();
        if (transcript) {
            youtubeTranscript = transcript;
            updateButtonText();
        }
        updateStatus('Idle', '#555');
    }

    function isYouTubeVideoPage() {
        return window.location.hostname.includes('youtube.com') && window.location.pathname === '/watch';
    }

    // === RateLimit Timer Functions ===
    function startRateLimitTimer() {
        rateLimitRemaining = 61;
        updateRateLimitDisplay();
        if (rateLimitInterval) clearInterval(rateLimitInterval);
        rateLimitInterval = setInterval(() => {
            rateLimitRemaining--;
            updateRateLimitDisplay();
            if (rateLimitRemaining <= 0) {
                clearInterval(rateLimitInterval);
                rateLimitInterval = null;
            }content = content.replace(/```json\n?|```\n?/g, '').replace(/}[^\]]*$/, '}').trim();
        }, 1000);
    }

    function updateRateLimitDisplay() {
        if (rateLimitRemaining > 0) {
            rateLimitDisplay.textContent = `RateLimit: ${rateLimitRemaining}s`;
            rateLimitDisplay.style.color = '#f5a623';
        } else {
            rateLimitDisplay.textContent = 'RateLimit: Ready';
            rateLimitDisplay.style.color = '#0c6';
        }
    }

    function isRateLimited() {
        return rateLimitRemaining > 0;
    }

    // === Markdown Parser ===
    function parseMarkdown(text) {
        return text
            .replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
            .replace(/`([^`]+)`/g, '<code>$1</code>')
            .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
            .replace(/__(.+?)__/g, '<strong>$1</strong>')
            .replace(/\*(.+?)\*/g, '<em>$1</em>')
            .replace(/_(.+?)_/g, '<em>$1</em>')
            .replace(/~~(.+?)~~/g, '<del>$1</del>')
            .replace(/^### (.+)$/gm, '<h4>$1</h4>')
            .replace(/^## (.+)$/gm, '<h3>$1</h3>')
            .replace(/^# (.+)$/gm, '<h2>$1</h2>')
            .replace(/^\s*[-*+] (.+)$/gm, '<li>$1</li>')
            .replace(/^\s*\d+\. (.+)$/gm, '<li>$1</li>')
            .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
            .replace(/\n/g, '<br>');
    }

    // === API Call ===
    async function summarizePage(text, lang) {
        const apiKey = getRandomApiKey();

        if (!apiKey) {
            return Promise.reject({ message: "No API Key found! Click ⚙️ to add keys." });
        }

        const prompt = `Summarize the following text in ${lang}. The summary is organised in blocks of topics. The summary must be concise.
                    Return the result in a json list composed of dictionaries with fields "title" (the title starts with a contextual colored emoji) and "text".
                    Don't add any other sentence like "Here is the summary".
                    Don't add any coding formatting/header like "\"\`\`\`json\".
                    Don't add any formatting to title or text, no formatting at all".
                    Exclude from the summary any advertisement, collaboration, promotion or sponsorization.
                    Here is the text: ${text}`;

        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: 'POST',
                url: 'https://api.airforce/v1/chat/completions',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${apiKey}`
                },
                data: JSON.stringify({
                    model: selectedModel,
                    messages: [{ role: 'user', content: prompt }]
                }),
                onload: r => {
                    try {
                        const response = JSON.parse(r.responseText);
                        if (r.status >= 200 && r.status < 300 && response.choices?.[0]) {
                            let content = response.choices[0].message.content;
                            //content = content.replace(/```json\n?|```\n?/g, '').replace("\ndiscord.gg/airforce","").replace("\n\nWant best roleplay experience?\nhttps://llmplayground.net", "").trim();
                            content = content.replace(/```json\n?|```\n?/g, '').replace(/\][^\]]*$/, ']').trim();
                            resolve({ status: r.status, text: content, ok: true });
                        } else {
                            reject({ message: response.error?.message || `API Error: ${r.status}` });
                        }
                    } catch (e) {
                        reject({ message: `Parse error: ${e.message}` });
                    }
                },
                onerror: () => reject({ message: 'Network error' })
            });
        });
    }

    // === Handlers ===
    function toggleSidebar() {
        isSidebarVisible = !isSidebarVisible;
        sidebar.style.transform = isSidebarVisible ? 'translateX(0)' : 'translateX(100%)';
        toggleButton.style.right = isSidebarVisible ? '300px' : '0';
        toggleButton.style.opacity = isSidebarVisible ? '1' : '0.3';
    }

    function toggleSettings() {
        isSettingsVisible = !isSettingsVisible;
        settingsContainer.style.display = isSettingsVisible ? 'flex' : 'none';
        summaryContainer.style.display = isSettingsVisible ? 'none' : 'block';
    }

    function updateButtonText() {
        const sel = window.getSelection().toString().trim();
        if (sel) {
            summarizeButton.textContent = `✨ "${sel.slice(0, 6)}..."`;
        } else if (youtubeTranscript) {
            summarizeButton.textContent = '📹️ Summary';
        } else {
            summarizeButton.textContent = '✨ Summary';
        }
    }

    function updateStatus(text, color) {
        statusDisplay.innerHTML = `<span style="color:${color}; margin-right: 4px;">● ${text}</span> - <span style="color:#0af;margin-left:4px">${selectedLanguage}</span>`;
    }

    function renderSummary(jsonText) {
        const c = document.createElement('div');
        try {
            JSON.parse(jsonText).forEach(b => {
                const card = document.createElement('div');
                card.className = 'card';
                const title = document.createElement('div');
                title.className = 'card-title';
                title.innerHTML = parseMarkdown(b.title);
                const text = document.createElement('div');
                text.className = 'card-text';
                text.innerHTML = parseMarkdown(b.text);
                card.append(title, text);
                c.appendChild(card);
            });
        } catch (e) {
            c.innerText = "Error parsing JSON response. Raw text:\n" + jsonText;
        }
        return c;
    }

    function handleSummarizeClick() {
        if (API_KEYS.length === 0) {
            alert("No API Keys configured. Please open Settings (⚙️) and add your keys.");
            if(!isSettingsVisible) toggleSettings();
            return;
        }

        if (isRateLimited()) {
            updateStatus(`Wait RateLimit`, '#f5a623');
            return;
        }

        if (loading) return;

        const selectedText = window.getSelection().toString().trim();
        let content;

        if (selectedText) {
            content = selectedText;
        } else if (youtubeTranscript) {
            content = youtubeTranscript;
        } else {
            content = document.body.innerText;
        }

        loading = true;
        summarizeButton.disabled = true;
        updateStatus('Loading...', '#f90');
        summaryContainer.style.display = 'none';
        settingsContainer.style.display = 'none'; // Ensure settings closed
        isSettingsVisible = false;

        summarizePage(content, selectedLanguage)
            .then(({ status, text, ok }) => {
                try {
                    summaryContainer.textContent = '';
                    summaryContainer.append(renderSummary(text));
                    updateStatus(`OK`, ok ? '#0c6' : '#fc0');
                } catch (e) {
                    summaryContainer.innerHTML = `<div style="color:#f55;font-size:11px">⚠️ ${e.message}</div>`;
                    updateStatus('Error', '#f55');
                }
            })
            .catch(e => {
                summaryContainer.innerHTML = `<div style="color:#f55;font-size:11px">⚠️ ${e.message}</div>`;
                updateStatus('Error', '#f55');
            })
            .finally(() => {
                loading = false;
                summarizeButton.disabled = false;
                updateButtonText();
                summaryContainer.style.display = 'block';
                startRateLimitTimer();
            });
    }

    // === UI Builder ===
    function createSidebarUI() {
        const host = document.createElement('div');
        const root = host.attachShadow({ mode: 'open' });

        const css = `
            *{box-sizing:border-box;margin:0;padding:0}

            /* Main Layout */
            .sidebar{position:fixed;right:0;top:0;width:300px;height:100vh;background:#0a0a0a;color:#fff;padding:10px;z-index:999999;font-family:system-ui,sans-serif;display:flex;flex-direction:column;gap:8px;transform:translateX(100%);transition:transform .2s ease;border-left:1px solid #444}
            .toggle{position:fixed;right:0;top:50%;transform:translateY(-50%);width:18px;height:48px;background:#151515;border:1px solid #444;border-right:none;border-radius:6px 0 0 6px;cursor:pointer;z-index:1000000;display:flex;align-items:center;justify-content:center;color:#555;font-size:14px;transition:right .2s,background .15s,opacity .3s;opacity:0.3}
            .toggle:hover{background:#1a1a1a;color:#888;opacity:1}

            /* Top Controls Row */
            .row{display:flex;gap:6px;align-items:center}

            /* Shared Styles for Uniform Height (30px) & Borders */
            select, button.sum, button.icon-btn {
                height:30px; border-radius:5px; border:1px solid #888; font-size:13px; cursor:pointer;
            }

            /* 1. Dropdown: Fills space, Gray BG */
            select{flex:1;width:0;padding:0 8px;background:#111;color:#aaa;outline:none}
            select:focus{border-color:#0af}

            /* 2. Settings Btn: Compact, Dark Gray BG */
            button.icon-btn{flex:0 0 auto;padding:0 10px;background:#333;color:#eee;display:inline-flex;align-items:center;justify-content:center}

            /* 3. Summary Btn: Compact, Blue BG */
            button.sum{flex:0 0 auto;width:auto;padding:0 10px;background:#0af;color:#000;font-weight:600;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;transition:opacity .15s}

            button:hover{opacity:.85}
            button:disabled{opacity:.4;cursor:not-allowed}

            /* Status Bar */
            .status-row{display:flex;justify-content:space-between;align-items:center;font-size:10px;padding:4px 0;border-bottom:1px solid #444}
            .status{color:#444}
            .ratelimit{color:#0c6;font-weight:500}

            /* Main Content (Flex Fill) */
            .summary{flex:1;overflow-y:auto;scrollbar-width:thin;scrollbar-color:#222 transparent;min-height:0}

            /* Settings Panel (Flex Fill) */
            .settings-panel{display:none;flex:1;flex-direction:column;gap:8px;border-top:1px solid #333;padding-top:10px;min-height:0}
            .settings-label{font-size:12px;color:#888}
            textarea.api-input{flex:1;width:100%;background:#111;color:#0f0;border:1px solid #444;padding:8px;font-family:monospace;font-size:11px;resize:none;border-radius:4px}
            textarea.api-input:focus{outline:none;border-color:#0af}

            /* Markdown & Cards */
            .card{padding:4px 0;margin-bottom:6px;border-bottom:1px solid #444}
            .card-title{font-weight:600;font-size:14px;color:#eee;margin-bottom:3px}
            .card-text{color:#888;font-size:13px;line-height:1.4}
            code{background:#1e1e1e;color:#e06c75;padding:1px 4px;border-radius:3px;font-family:'SF Mono',Consolas,monospace;font-size:12px}
            pre{background:#1e1e1e;border-radius:4px;padding:8px;margin:4px 0;overflow-x:auto}
            pre code{background:none;padding:0;color:#abb2bf;display:block;white-space:pre}
            strong{color:#fff;font-weight:600}
            em{color:#aaa;font-style:italic}
            h2,h3,h4{color:#fff;margin:6px 0 4px}
            li{margin-left:12px;list-style:disc;color:#888}
            a{color:#0af;text-decoration:none}
        `;

        const style = document.createElement('style');
        style.textContent = css;

        const sidebar = document.createElement('div');
        sidebar.className = 'sidebar';

        const toggleButton = document.createElement('div');
        toggleButton.className = 'toggle';
        toggleButton.innerHTML = '✨';

        // Top Row: Model | Settings | SummaryBtn
        const row = document.createElement('div');
        row.className = 'row';

        const modelSelect = document.createElement('select');
        models.forEach((m, i) => {
            const o = document.createElement('option');
            o.value = i;
            o.textContent = m.label;
            modelSelect.appendChild(o);
        });

        const settingsButton = document.createElement('button');
        settingsButton.className = 'icon-btn';
        settingsButton.textContent = '⚙️';
        settingsButton.title = "Configure API Keys";

        const summarizeButton = document.createElement('button');
        summarizeButton.className = 'sum';
        summarizeButton.textContent = '✨ Summary';

        row.append(modelSelect, settingsButton, summarizeButton);

        // Status Row
        const statusRow = document.createElement('div');
        statusRow.className = 'status-row';
        const statusDisplay = document.createElement('div');
        statusDisplay.className = 'status';
        const rateLimitDisplay = document.createElement('div');
        rateLimitDisplay.className = 'ratelimit';
        rateLimitDisplay.textContent = 'RateLimit: Ready';
        statusRow.append(statusDisplay, rateLimitDisplay);

        // Summary Container
        const summaryContainer = document.createElement('div');
        summaryContainer.className = 'summary';

        // Settings Container (Hidden by default)
        const settingsContainer = document.createElement('div');
        settingsContainer.className = 'settings-panel';

        const settingsLabel = document.createElement('div');
        settingsLabel.className = 'settings-label';
        settingsLabel.textContent = 'Paste API Keys (one per line):';

        const apiKeyInput = document.createElement('textarea');
        apiKeyInput.className = 'api-input';
        apiKeyInput.placeholder = "sk-...\nsk-...";

        const saveKeysButton = document.createElement('button');
        saveKeysButton.className = 'sum';
        saveKeysButton.textContent = '💾 Save Keys';
        saveKeysButton.style.marginTop = '8px';

        settingsContainer.append(settingsLabel, apiKeyInput, saveKeysButton);

        sidebar.append(row, statusRow, summaryContainer, settingsContainer);
        root.append(style, sidebar, toggleButton);

        return {
            shadowRoot: root, sidebar, toggleButton, summarizeButton,
            statusDisplay, summaryContainer, modelSelect, rateLimitDisplay,
            settingsButton, settingsContainer, apiKeyInput, saveKeysButton
        };
    }

    // === YouTube SPA Navigation Observer ===
    if (isYouTubeVideoPage()) setTimeout(initYouTubeTranscript, 3000);

    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            if (isYouTubeVideoPage()) {
                youtubeTranscript = null;
                updateButtonText();
                setTimeout(initYouTubeTranscript, 3000);
            } else {
                youtubeTranscript = null;
                updateButtonText();
            }
        }
    }).observe(document, { subtree: true, childList: true });

})();