Summarize with AI

Adds a button or keyboard shortcut to summarize articles, news, and similar content using the OpenAI API (gpt-4o-mini model). The summary is displayed in an overlay with enhanced styling and a loading animation.

目前為 2024-10-07 提交的版本,檢視 最新版本

作者
insign
評價
0 0 0
版本
2024.10.11.1516
建立日期
2024-09-19
更新日期
2024-10-07
尺寸
24.1 KB
授權條款
GPL-3.0
腳本執行於
所有網站

// ==UserScript== // @name Summarize with AI // @namespace https://github.com/insign/summarize-with-ai // @version 2024.10.11.1517 // @description Adds a button or keyboard shortcut to summarize articles, news, and similar content using the OpenAI API (gpt-4o-mini model). The summary is displayed in an overlay with enhanced styling and a loading animation. // @author Hélio // @license GPL-3.0 // @match :///* // @grant GM.addStyle // @grant GM.xmlHttpRequest // @grant GM.setValue // @grant GM.getValue // @grant GM.getResourceURL // @connect api.openai.com // @require https://www.unpkg.com/@mozilla/[email protected]/Readability.js // ==/UserScript==

(function() { 'use strict';

/*** Constants ***/
const BUTTON_ID = 'summarize-button';
const OVERLAY_ID = 'summarize-overlay';
const CLOSE_BUTTON_ID = 'summarize-close';
const CONTENT_ID = 'summarize-content';
const ERROR_ID = 'summarize-error';
const API_URL = 'https://api.openai.com/v1/chat/completions';
const VERSION = '2024.10.11.1517';

/*** Initialization ***/

// Variable to store whether the current page is an article
let isArticle = false;
// Variables to store article title and content
let articleTitle = '';
let articleContent = '';

// Initialize the script
initialize();

/**
 * Initializes the userscript by detecting if the page is an article,
 * setting up the summarize button and keyboard shortcuts accordingly.
 */
async function initialize() {
    try {
        // Use Readability to parse the article
        const articleData = getArticleData();
        if (articleData) {
            isArticle = true;
            articleTitle = articleData.title;
            articleContent = articleData.content;
            addSummarizeButton();
            setupKeyboardShortcuts();
        } else {
            isArticle = false;
            hideSummarizeButton();
            disableKeyboardShortcuts();
            console.log('Summarize with AI: No article detected on this page.');
            return; // Disable further script functionality
        }

        // Set up event listeners to handle focus changes
        setupFocusListeners();
    } catch (error) {
        console.error('Initialization error:', error);
        showErrorNotification('Erro durante a inicialização.');
    }
}

/*** Function Definitions ***/

/**
 * Uses Mozilla's Readability to extract the article's title and content.
 * @returns {Object|null} An object containing the title and content if an article is found, otherwise null.
 */
function getArticleData() {
    try {
        const documentClone = document.cloneNode(true);
        const readability = new Readability(documentClone);
        const article = readability.parse();

        if (article && article.content && article.title) {
            return {
                title: article.title,
                content: article.textContent // Using textContent to send plain text to the API
            };
        } else {
            return null;
        }
    } catch (error) {
        console.error('Readability parsing error:', error);
        return null;
    }
}

/**
 * Adds the summarize button to the page with standardized styling.
 * The button is fixed at the bottom-right corner and has a high z-index.
 */
function addSummarizeButton() {
    // Create the button element
    const button = document.createElement('div');
    button.id = BUTTON_ID;
    button.innerText = 'S';
    document.body.appendChild(button);

    // Add click and double-click event listeners
    button.addEventListener('click', onSummarizeClick);
    button.addEventListener('dblclick', onApiKeyReset);

    // Inject CSS styles using GM.addStyle to standardize them and prevent inheritance
    GM.addStyle(`
        /* Summarize Button Styling */
        #${BUTTON_ID} {
            position: fixed;
            bottom: 20px;
            right: 20px;
            width: 60px;
            height: 60px;
            background-color: rgba(0, 123, 255, 0.9);
            color: white;
            font-size: 28px;
            font-weight: bold;
            text-align: center;
            line-height: 60px;
            border-radius: 50%;
            cursor: pointer;
            z-index: 100001; /* Increased z-index to ensure visibility */
            box-shadow: 0 2px 5px rgba(0,0,0,0.3);
            transition: background-color 0.3s, transform 0.3s;
            user-select: none;
            font-family: Arial, sans-serif;
        }
        /* Hover effect for the summarize button */
        #${BUTTON_ID}:hover {
            background-color: rgba(0, 123, 255, 1);
            transform: scale(1.1);
        }

        /* Summary Overlay Styling */
        #${OVERLAY_ID} {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: #ffffff;
            z-index: 100002; /* Increased z-index to ensure it's above all elements */
            padding: 30px;
            box-shadow: 0 0 15px rgba(0,0,0,0.5);
            overflow: auto;
            font-size: 1.2em;
            max-width: 90%;
            max-height: 90%;
            border-radius: 10px;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            font-family: Arial, sans-serif;
        }

        /* Close Button Styling */
        #${CLOSE_BUTTON_ID} {
            position: absolute;
            top: 15px;
            right: 15px;
            cursor: pointer;
            font-size: 26px;
            transition: transform 0.2s;
            color: #555555;
        }
        /* Tooltip for Close Button on Hover */
        #${CLOSE_BUTTON_ID}:hover::after {
            content: "ESC";
            position: absolute;
            top: -12px;
            right: -10px;
            background: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 2px 6px;
            border-radius: 4px;
            font-size: 12px;
            white-space: nowrap;
        }

        /* Summary Content Styling */
        #${CONTENT_ID} {
            margin-top: 40px;
            width: 100%;
            color: #333333;
        }

        /* Error Notification Styling */
        #${ERROR_ID} {
            position: fixed;
            bottom: 20px;
            left: 20px;
            background-color: rgba(255,0,0,0.8);
            color: white;
            padding: 10px 20px;
            border-radius: 5px;
            z-index: 100003; /* Higher than overlay */
            font-size: 14px;
            font-family: Arial, sans-serif;
        }

        /* Loading Text Animation */
        .glow {
            font-size: 1.5em;
            color: #333;
            text-align: center;
            animation: glow 2s ease-in-out infinite alternate;
        }
        @keyframes glow {
            from {
                color: #4b6cb7;
                text-shadow: 0 0 10px #4b6cb7, 0 0 20px #4b6cb7, 0 0 30px #4b6cb7;
            }
            to {
                color: #182848;
                text-shadow: 0 0 20px #8e2de2, 0 0 30px #8e2de2, 0 0 40px #8e2de2;
            }
        }

        /* Media Queries for Mobile Devices */
        @media (max-width: 768px) {
            #${BUTTON_ID} {
                width: 70px;
                height: 70px;
                font-size: 32px;
                line-height: 70px;
                bottom: 15px;
                right: 15px;
            }
            #${OVERLAY_ID} {
                width: 95%;
                height: 95%;
                padding: 25px;
            }
            #${ERROR_ID} {
                bottom: 15px;
                left: 15px;
                font-size: 12px;
            }
        }

        /* Additional Adjustments for Very Small Screens */
        @media (max-width: 480px) {
            #${BUTTON_ID} {
                width: 80px;
                height: 80px;
                font-size: 36px;
                line-height: 80px;
                bottom: 10px;
                right: 10px;
            }
            #${OVERLAY_ID} {
                padding: 20px;
            }
            #${ERROR_ID} {
                padding: 8px 16px;
                font-size: 11px;
            }
        }

        /* Remove Default Bullet Points from Lists */
        #${CONTENT_ID} ul {
            list-style: none;
            padding: 0;
        }
    `);
}

/**
 * Sets up keyboard shortcuts by adding a keydown event listener.
 * The 'S' key triggers the summarization unless an input element is focused.
 */
function setupKeyboardShortcuts() {
    document.addEventListener('keydown', handleKeyDown);
}

/**
 * Disables keyboard shortcuts by removing the keydown event listener.
 */
function disableKeyboardShortcuts() {
    document.removeEventListener('keydown', handleKeyDown);
}

/**
 * Handles the keydown event to trigger summarization when 'S' key is pressed.
 * Ignores the event if an input, textarea, or contenteditable element is focused.
 * @param {KeyboardEvent} e - The keyboard event.
 */
function handleKeyDown(e) {
    // Check if any input-related element is focused
    const activeElement = document.activeElement;
    const isInput = activeElement && (['INPUT', 'TEXTAREA', 'SELECT'].includes(activeElement.tagName) || activeElement.isContentEditable);
    if (isInput) {
        hideSummarizeButton();
        return;
    }

    // If 'S' or 's' is pressed, trigger summarization
    if (e.key.toLowerCase() === 's') {
        e.preventDefault(); // Prevent default behavior
        onSummarizeShortcut();
    } else {
        // Show the summarize button if 'S' is not pressed and not in input
        showSummarizeButton();
    }
}

/**
 * Sets up listeners to monitor focus changes on the page.
 * Hides the summarize button when an input element is focused and shows it otherwise.
 */
function setupFocusListeners() {
    // Listen for focusin and focusout events to handle button visibility
    document.addEventListener('focusin', handleFocusChange);
    document.addEventListener('focusout', handleFocusChange);
}

/**
 * Handles focus changes to show or hide the summarize button.
 * @param {FocusEvent} e - The focus event.
 */
function handleFocusChange(e) {
    const activeElement = document.activeElement;
    const isInput = activeElement && (['INPUT', 'TEXTAREA', 'SELECT'].includes(activeElement.tagName) || activeElement.isContentEditable);

    if (isInput) {
        hideSummarizeButton();
    } else if (isArticle) {
        showSummarizeButton();
    }
}

/**
 * Shows the summarize button by setting its display to block.
 */
function showSummarizeButton() {
    const button = document.getElementById(BUTTON_ID);
    if (button) {
        button.style.display = 'block';
    }
}

/**
 * Hides the summarize button by setting its display to none.
 */
function hideSummarizeButton() {
    const button = document.getElementById(BUTTON_ID);
    if (button) {
        button.style.display = 'none';
    }
}

/**
 * Handles the click event on the summarize button to initiate summarization.
 */
function onSummarizeClick() {
    processSummarization();
}

/**
 * Handles the keyboard shortcut for summarization when 'S' key is pressed.
 * Alerts the user if the page might not be an article but proceeds to summarize.
 */
function onSummarizeShortcut() {
    if (!isArticle) {
        alert('Esta página pode não ser um artigo. Continuando para resumir de qualquer maneira.');
    }
    processSummarization();
}

/**
 * Initiates the summarization process by obtaining the API key,
 * preparing the article data, displaying the loading overlay,
 * and sending the data to the OpenAI API.
 */
async function processSummarization() {
    try {
        const apiKey = await getApiKey();
        if (!apiKey) {
            return;
        }

        // Prepare the data to send to the API
        const payload = {
            title: articleTitle,
            content: articleContent
        };

        // Display the overlay com animação de carregamento
        showSummaryOverlay('<p class="glow">Gerando resumo...</p>');

        // Envie os dados para a API da OpenAI para resumir
        await summarizeContent(apiKey, payload);
    } catch (error) {
        showErrorNotification('Erro: Falha ao iniciar a sumarização.');
        updateSummaryOverlay('<p>Erro: Falha ao iniciar a sumarização.</p>');
        console.error('Erro no processo de sumarização:', error);
    }
}

/**
 * Handles the double-click event on the summarize button to reset the API key.
 */
function onApiKeyReset() {
    const newKey = prompt('Por favor, insira sua chave de API da OpenAI:', '');
    if (newKey) {
        GM.setValue('openai_api_key', newKey.trim()).then(function() {
            alert('Chave de API atualizada com sucesso.');
        }).catch(function(error) {
            alert('Erro ao atualizar a chave de API.');
            console.error('Erro ao atualizar a chave de API:', error);
        });
    }
}

/**
 * Retrieves the OpenAI API key from storage.
 * If not found, prompts the user to input it and saves it.
 * @returns {Promise<string|null>} Promise resolving to the API key or null if not provided.
 */
async function getApiKey() {
    try {
        let apiKey = await GM.getValue('openai_api_key');
        if (apiKey) {
            return apiKey.trim();
        } else {
            const userInput = prompt('Por favor, insira sua chave de API da OpenAI:', '');
            if (userInput) {
                apiKey = userInput.trim();
                await GM.setValue('openai_api_key', apiKey);
                return apiKey;
            } else {
                alert('É necessária uma chave de API para gerar um resumo.');
                return null;
            }
        }
    } catch (error) {
        console.error('Erro ao recuperar a chave de API:', error);
        alert('Falha ao recuperar a chave de API.');
        return null;
    }
}

/**
 * Displays the summary overlay with the provided content.
 * Adds functionality to close the overlay by clicking the close button,
 * clicking outside the content area, or pressing the 'Escape' key.
 * @param {string} content - HTML content to display inside the overlay.
 */
function showSummaryOverlay(content) {
    // Check if the overlay already exists to prevent multiple instances
    if (document.getElementById(OVERLAY_ID)) {
        updateSummaryOverlay(content);
        return;
    }

    // Create the overlay element
    const overlay = document.createElement('div');
    overlay.id = OVERLAY_ID;
    overlay.innerHTML = `
        <div id="${CLOSE_BUTTON_ID}">&times;</div>
        <div id="${CONTENT_ID}">${content}</div>
    `;
    document.body.appendChild(overlay);

    // Disable background scrolling when the overlay is open
    document.body.style.overflow = 'hidden';

    // Add event listener to the close button
    document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay);

    // Add event listener to close the overlay when clicking outside the content area
    overlay.addEventListener('click', function(e) {
        if (e.target === overlay) {
            closeOverlay();
        }
    });

    // Add event listener for the 'Escape' key to close the overlay
    document.addEventListener('keydown', onEscapePress);

    /**
     * Handles the 'Escape' key press to close the overlay.
     * @param {KeyboardEvent} e - The keyboard event.
     */
    function onEscapePress(e) {
        if (e.key === 'Escape') {
            closeOverlay();
        }
    }

    /**
     * Closes the summary overlay and re-enables background scrolling.
     */
    function closeOverlay() {
        const existingOverlay = document.getElementById(OVERLAY_ID);
        if (existingOverlay) {
            existingOverlay.remove();
            document.body.style.overflow = '';
            document.removeEventListener('keydown', onEscapePress);
        }
    }
}

/**
 * Updates the content within the summary overlay.
 * @param {string} content - New HTML content to display.
 */
function updateSummaryOverlay(content) {
    const contentDiv = document.getElementById(CONTENT_ID);
    if (contentDiv) {
        contentDiv.innerHTML = content;
    }
}

/**
 * Displays an error notification at the bottom-left corner of the page.
 * @param {string} message - The error message to display.
 */
function showErrorNotification(message) {
    // Check if an error notification already exists
    if (document.getElementById(ERROR_ID)) {
        document.getElementById(ERROR_ID).innerText = message;
        return;
    }

    // Create the error notification element
    const errorDiv = document.createElement('div');
    errorDiv.id = ERROR_ID;
    errorDiv.innerText = message;
    document.body.appendChild(errorDiv);

    // Remove the error notification after 4 seconds
    setTimeout(function() {
        const existingError = document.getElementById(ERROR_ID);
        if (existingError) {
            existingError.remove();
        }
    }, 4000);
}

/**
 * Sends the article data to the OpenAI API to generate a summary.
 * Handles the API response and updates the overlay with the summary or error messages.
 * @param {string} apiKey - The OpenAI API key.
 * @param {Object} payload - An object containing the title and content of the article.
 */
async function summarizeContent(apiKey, payload) {
    try {
        // Prepare the API request payload
        const userLanguage = navigator.language || 'pt-BR'; // Default to Portuguese if navigator.language is undefined

        const requestData = {
            model: 'gpt-4o-mini',
            messages: [
                {
                    role: 'system',
                    content: `Você é um assistente útil que resume artigos com base no título e conteúdo fornecidos. Você deve gerar um resumo conciso que inclua uma breve introdução, seguida por uma lista de tópicos e termine com uma curta conclusão. Para os tópicos, use emojis apropriados como marcadores, e os tópicos devem consistir em títulos descritivos que resumem o assunto de cada tópico.

Você deve sempre usar tags HTML para estruturar o texto do resumo. O título deve estar envolto em tags

, e você deve sempre usar o idioma do usuário além do idioma original do artigo. O HTML gerado deve estar pronto para ser injetado no local de destino, e você nunca deve usar markdown.

Estrutura requerida:

  • Use

    para o título do resumo

  • Use parágrafos para a introdução e conclusão
  • Use emojis apropriados para os tópicos
  • Não adicione textos como "Resumo do Artigo" ou "Sumário do artigo" no resumo, nem "Introdução", "Tópicos", "Conclusão", etc.

Idioma do usuário: ${userLanguage}. Adapte o texto para ser curto, conciso e informativo. }, { role: 'user', content:Título: ${payload.title}\n\nConteúdo: ${payload.content}` } ], max_tokens: 500, temperature: 0.5, n: 1, stream: false };

        // Envie a requisição para a API da OpenAI usando GM.xmlHttpRequest
        GM.xmlHttpRequest({
            method: 'POST',
            url: API_URL,
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${apiKey}`
            },
            data: JSON.stringify(requestData),
            onload: function(response) {
                if (response && response.status === 200) {
                    try {
                        const resData = JSON.parse(response.responseText);
                        if (resData.choices && resData.choices.length > 0) {
                            const summary = resData.choices[0].message.content;

                            // Substitui quebras de linha por <br> para renderização HTML
                            const formattedSummary = summary.replace(/\n/g, '<br>');

                            // Atualiza o overlay com o resumo gerado
                            updateSummaryOverlay(formattedSummary);
                        } else {
                            showErrorNotification('Erro: Resposta inválida da API.');
                            updateSummaryOverlay('<p>Erro: Resposta inválida da API.</p>');
                        }
                    } catch (parseError) {
                        showErrorNotification('Erro: Falha ao analisar a resposta da API.');
                        updateSummaryOverlay('<p>Erro: Falha ao analisar a resposta da API.</p>');
                        console.error('Erro ao analisar a resposta da API:', parseError);
                    }
                } else if (response && response.status === undefined) {
                    // Lida com casos onde response.status está indefinido
                    showErrorNotification('Erro: Resposta inesperada da API.');
                    console.error('Resposta da API sem status:', response);
                    updateSummaryOverlay('<p>Erro: Resposta inesperada da API.</p>');
                } else if (response && response.status === 401) {
                    // Lida com acesso não autorizado (chave de API inválida)
                    showErrorNotification('Erro: Chave de API inválida.');
                    updateSummaryOverlay('<p>Erro: Chave de API inválida.</p>');
                } else {
                    // Lida com outros tipos de erros
                    showErrorNotification(`Erro: Falha ao recuperar o resumo. Status: ${response.status || 'N/A'}`);
                    updateSummaryOverlay(`<p>Erro: Falha ao recuperar o resumo. Status: ${response.status || 'N/A'}</p>`);
                }
            },
            onerror: function() {
                // Lida com erros de rede
                showErrorNotification('Erro: Problema de rede.');
                updateSummaryOverlay('<p>Erro: Problema de rede.</p>');
            },
            onabort: function() {
                // Lida com aborto da requisição
                showErrorNotification('Requisição abortada.');
                updateSummaryOverlay('<p>Requisição abortada.</p>');
            }
        });
    } catch (error) {
        showErrorNotification('Erro: Falha ao comunicar com a API.');
        updateSummaryOverlay('<p>Erro: Falha ao comunicar com a API.</p>');
        console.error('Erro de comunicação com a API:', error);
    }
}

})();