AI Page Summarizer

Summarize webpage or selected text via remote API key

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();