AI Page Summarizer

Summarize webpage or selected text via remote API key

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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

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

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

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

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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

})();