您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Single-button AI summarization (OpenAI/Gemini) with model selection dropdown for articles/news. Uses Alt+S shortcut. Long press 'S' to select model. Allows adding custom models. Adapts summary overlay to system dark mode and mobile viewports.
当前为
// ==UserScript== // @name Summarize with AI // @namespace https://github.com/insign/userscripts // @version 2025.05.03.2014 // @description Single-button AI summarization (OpenAI/Gemini) with model selection dropdown for articles/news. Uses Alt+S shortcut. Long press 'S' to select model. Allows adding custom models. Adapts summary overlay to system dark mode and mobile viewports. // @author Hélio <[email protected]> // @license WTFPL // @match *://*/* // @grant GM.addStyle // @grant GM.xmlHttpRequest // @grant GM.setValue // @grant GM.getValue // @connect api.openai.com // @connect generativelanguage.googleapis.com // @require https://cdnjs.cloudflare.com/ajax/libs/readability/0.6.0/Readability.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/readability/0.6.0/Readability-readerable.min.js // ==/UserScript== (function () { 'use strict' // --- Constantes --- // IDs dos elementos da interface do script const BUTTON_ID = 'summarize-button' // Botão principal flutuante 'S' const DROPDOWN_ID = 'model-dropdown' // Dropdown de seleção de modelo const OVERLAY_ID = 'summarize-overlay' // Overlay de fundo para o sumário const CLOSE_BUTTON_ID = 'summarize-close' // Botão de fechar no overlay const CONTENT_ID = 'summarize-content' // Div que contém o texto do sumário const ERROR_ID = 'summarize-error' // Div para exibir notificações de erro const ADD_MODEL_ITEM_ID = 'add-custom-model' // ID para o item "Adicionar Modelo" no dropdown const RETRY_BUTTON_ID = 'summarize-retry-button' // ID para o botão "Tentar Novamente" no overlay de erro // Chave para armazenar modelos customizados no GM storage const CUSTOM_MODELS_KEY = 'custom_ai_models' // Limite de tokens padrão const DEFAULT_MAX_TOKENS = 1000 // Limite de tokens alto (para modelos específicos) const HIGH_MAX_TOKENS = 1500 // Tempo para considerar long press (em milissegundos) const LONG_PRESS_DURATION = 500 // Configuração dos serviços e modelos de IA *padrão* suportados // Nova estrutura: models é um array de objetos com id, name (opcional), params (opcional) const MODEL_GROUPS = { openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1/chat/completions', models: [ {id: 'o4-mini', name: 'o4 mini (better)', params: {max_completion_tokens: HIGH_MAX_TOKENS}}, {id: 'o3-mini', name: 'o3 mini', params: {max_completion_tokens: HIGH_MAX_TOKENS}}, // Nome pode precisar de ajuste {id: 'gpt-4.1', name: 'GPT-4.1'}, // Usa params padrão {id: 'gpt-4.1-mini', name: 'GPT-4.1 mini'}, {id: 'gpt-4.1-nano', name: 'GPT-4.1 nano (faster)'}, ], // Parâmetros padrão específicos para OpenAI (se não definidos no modelo) defaultParams: {max_completion_tokens: DEFAULT_MAX_TOKENS} }, gemini: { name: 'Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models/', models: [ {id: 'gemini-2.5-flash-preview-04-17', name: 'Gemini 2.5 Flash (faster)', params: {maxOutputTokens: HIGH_MAX_TOKENS} }, {id: 'gemini-2.5-pro-exp-03-25', name: 'Gemini 2.5 Pro (better)', params: {maxOutputTokens: HIGH_MAX_TOKENS}}, ], // Parâmetros padrão específicos para Gemini (se não definidos no modelo) defaultParams: {maxOutputTokens: DEFAULT_MAX_TOKENS} // Mantemos o padrão original aqui }, } // Template do prompt enviado para a IA // Instruções atualizadas para usar as classes CSS específicas de qualidade const PROMPT_TEMPLATE = (title, content, lang) => `You are a summarizer bot that provides clear and affirmative explanations of content. Generate a concise summary that includes: - 2-sentence introduction - Relevant emojis as bullet points - No section headers - Use HTML formatting, never use \`\`\` code blocks, never use markdown. - After the last bullet point add a 2-sentence conclusion with your own opinion based on your general knowledge, including if you agree or disagree and why. Give your opinion as a human. Start this conclusion with "<strong>Opinion:</strong> ". Do not add things like "I agree" or "I disagree", instead just your opinion. - User language to be used in the entire summary: ${lang} - Before everything, add quality of the article, like "<strong>Article Quality:</strong> <span class=article-good>8/10</span>", where 1 is bad and 10 is excellent. - For the quality class use: <span class=article-excellent>9/10</span> (or 10) <span class=article-good>8/10</span> <span class=article-average>7/10</span> <span class=article-bad>6/10</span> <span class=article-very-bad>5/10</span> (or less) - "Opinion:", "Article Quality:" should be in user language, e.g. "Opinião:", "Qualidade do artigo:" for Português. Article Title: ${title} Article Content: ${content}` // --- Variáveis de Estado --- let activeModel = 'gemini-2.5-flash-preview-04-17' // ID do modelo ativo selecionado por padrão ou pelo usuário let articleData = null // Armazena o título e conteúdo extraído do artigo { title, content } let customModels = [] // Array para armazenar modelos customizados carregados do storage { id, service } let longPressTimer = null // Timer para detectar long press no botão 'S' let isLongPress = false // Flag para indicar se ocorreu long press // --- Funções Principais --- /** * Função principal de inicialização do script. * Carrega modelos customizados, adiciona listener de teclado, * tenta extrair dados do artigo, e se bem-sucedido, adiciona o botão e listeners de foco. */ async function initialize() { customModels = await getCustomModels() // Carrega modelos customizados do storage document.addEventListener('keydown', handleKeyPress) // Listener para atalhos (Alt+S, Esc) articleData = getArticleData() // Tenta extrair o conteúdo do artigo if (articleData) { // Se encontrou conteúdo legível: addSummarizeButton() // Adiciona o botão flutuante e o dropdown showElement(BUTTON_ID) // Torna o botão visível setupFocusListeners() // Configura para esconder/mostrar botão em campos de input // Define o último modelo usado (ou padrão) como ativo activeModel = await GM.getValue('last_used_model', activeModel) } } /** * Tenta extrair o conteúdo principal da página usando a biblioteca Readability.js. * @returns {object|null} - Um objeto { title, content } se bem-sucedido, ou null se não for legível ou ocorrer erro. */ function getArticleData() { try { const docClone = document.cloneNode(true) // Clona o documento para não modificar o original // Remove elementos que podem interferir com a extração docClone.querySelectorAll('script, style, noscript, iframe, figure, img, svg, header, footer, nav').forEach(el => el.remove()) // Verifica se a página é provavelmente legível if (!isProbablyReaderable(docClone)) { console.log('Summarize with AI: Page not detected as readerable.') return null } const reader = new Readability(docClone) const article = reader.parse() // Retorna dados se o conteúdo foi extraído e não está vazio return (article?.content && article.textContent?.trim()) ? {title: article.title, content: article.textContent.trim()} : null } catch (error) { console.error('Summarize with AI: Article parsing failed:', error) return null // Retorna null em caso de erro } } /** * Adiciona o botão flutuante 'S' e o dropdown de seleção de modelo ao DOM. * Configura os event listeners do botão (click, long press) e injeta estilos. */ function addSummarizeButton() { // Evita adicionar o botão múltiplas vezes if (document.getElementById(BUTTON_ID)) return // Cria o botão 'S' const button = document.createElement('div') button.id = BUTTON_ID button.textContent = 'S' // Texto simples e pequeno button.title = 'Summarize (Alt+S) / Long Press to Select Model' // Tooltip atualizado (sem dblclick) document.body.appendChild(button) // Cria o dropdown (inicialmente oculto) const dropdown = createDropdownElement() // Cria o elemento base do dropdown document.body.appendChild(dropdown) populateDropdown(dropdown) // Preenche o dropdown com modelos // Listener para clique simples: Inicia a sumarização com o modelo ativo button.addEventListener('click', () => { // Só executa se não foi um long press if (!isLongPress) { processSummarization() // Chama a função principal de sumarização } // Reseta a flag de long press para o próximo clique isLongPress = false }) // Listener para Long Press: Mostra/esconde o dropdown button.addEventListener('mousedown', (e) => { // Inicia o timer para detectar long press isLongPress = false // Reseta a flag clearTimeout(longPressTimer) // Limpa timer anterior se houver longPressTimer = setTimeout(() => { isLongPress = true // Marca que ocorreu long press toggleDropdown(e) // Abre/fecha o dropdown }, LONG_PRESS_DURATION) }) // Listener para soltar o botão (cancela o timer se antes do tempo) button.addEventListener('mouseup', () => { clearTimeout(longPressTimer) }) // Listener se o mouse sair do botão (cancela o timer) button.addEventListener('mouseleave', () => { clearTimeout(longPressTimer) }) // Listener para clique fora do dropdown para fechá-lo document.addEventListener('click', handleOutsideClick) // Injeta os estilos CSS necessários para a interface injectStyles() } // --- Funções de UI (Dropdown, Overlay, Notificações) --- /** * Cria o elemento base (container) do dropdown. * @returns {HTMLElement} - O elemento div do dropdown, inicialmente vazio e oculto. */ function createDropdownElement() { const dropdown = document.createElement('div') dropdown.id = DROPDOWN_ID dropdown.style.display = 'none' // Começa oculto return dropdown } /** * Preenche o elemento dropdown com os grupos de modelos (padrão e customizados) * e a opção para adicionar novos modelos. Adiciona links de reset de API Key. * @param {HTMLElement} dropdownElement - O elemento do dropdown a ser preenchido. */ function populateDropdown(dropdownElement) { dropdownElement.innerHTML = '' // Limpa conteúdo anterior Object.entries(MODEL_GROUPS).forEach(([service, group]) => { // Combina modelos padrão e customizados para este serviço const standardModels = group.models || [] // Array de objetos {id, name?, params?} const serviceCustomModels = customModels .filter(m => m.service === service) // Filtra customizados por serviço {id, service} .map(m => ({id: m.id})) // Mapeia para o formato {id}, sem name ou params definidos aqui const allModelObjects = [...standardModels, ...serviceCustomModels] // Remove duplicatas baseadas no ID (case-insensitive) .reduce((acc, model) => { if (!acc.some(existing => existing.id.toLowerCase() === model.id.toLowerCase())) { acc.push(model) } return acc }, []) .sort((a, b) => a.id.localeCompare(b.id)) // Ordena alfabeticamente pelo ID if (allModelObjects.length > 0) { const groupDiv = document.createElement('div') groupDiv.className = 'model-group' // Cria o cabeçalho com link de reset groupDiv.appendChild(createHeader(group.name, service)) // Adiciona cada item de modelo allModelObjects.forEach(modelObj => groupDiv.appendChild(createModelItem(modelObj))) dropdownElement.appendChild(groupDiv) } }) // Adiciona separador e item "+ Adicionar" const separator = document.createElement('hr') separator.style.margin = '8px 0' separator.style.border = 'none' separator.style.borderTop = '1px solid #eee' dropdownElement.appendChild(separator) dropdownElement.appendChild(createAddModelItem()) } /** * Cria um elemento de cabeçalho para um grupo de modelos no dropdown, * incluindo um link para resetar a API Key do serviço. * @param {string} text - O texto do cabeçalho (nome do serviço). * @param {string} service - A chave do serviço ('openai' ou 'gemini'). * @returns {HTMLElement} - O elemento div do cabeçalho. */ function createHeader(text, service) { const headerContainer = document.createElement('div') headerContainer.className = 'group-header-container' // Container para flex layout const headerText = document.createElement('span') // Span para o texto headerText.className = 'group-header-text' headerText.textContent = text const resetLink = document.createElement('a') // Link para resetar resetLink.href = '#' resetLink.textContent = 'Reset Key' resetLink.className = 'reset-key-link' resetLink.title = `Reset ${text} API Key` resetLink.addEventListener('click', (e) => { e.preventDefault() // Previne navegação e.stopPropagation() // Impede que feche o dropdown handleApiKeyReset(service) // Chama o reset para o serviço específico }) headerContainer.appendChild(headerText) headerContainer.appendChild(resetLink) return headerContainer } /** * Cria um item clicável para um modelo específico no dropdown. * Usa a nova estrutura de objeto do modelo. * @param {object} modelObj - O objeto do modelo { id, name?, params? }. * @returns {HTMLElement} - O elemento div do item do modelo. */ function createModelItem(modelObj) { const item = document.createElement('div') item.className = 'model-item' // Usa o nome amigável se disponível, senão o ID item.textContent = modelObj.name || modelObj.id // Adiciona um marcador visual se for o modelo ativo atualmente if (modelObj.id === activeModel) { item.style.fontWeight = 'bold' item.style.color = '#1A73E8' // Azul para destacar } // Listener de clique: seleciona o ID do modelo, esconde dropdown e inicia sumarização item.addEventListener('click', async () => { activeModel = modelObj.id // Define o ID do modelo ativo await GM.setValue('last_used_model', activeModel) // Salva a última seleção hideElement(DROPDOWN_ID) // Esconde o dropdown processSummarization() // Inicia o processo de sumarização }) return item } /** * Cria o item clicável "+ Adicionar Modelo Customizado" no dropdown. * @returns {HTMLElement} - O elemento div do item. */ function createAddModelItem() { const item = document.createElement('div') item.id = ADD_MODEL_ITEM_ID item.className = 'model-item add-model-item' // Classe adicional para estilização item.textContent = '+ Add Custom Model' // Listener de clique: inicia o fluxo para adicionar um novo modelo item.addEventListener('click', async (e) => { e.stopPropagation() // Impede que feche o dropdown hideElement(DROPDOWN_ID) // Esconde o dropdown antes de mostrar os prompts await handleAddModel() }) return item } /** * Mostra ou esconde o dropdown de seleção de modelo. * @param {Event} [e] - O objeto do evento de clique/mousedown (opcional, para stopPropagation). */ function toggleDropdown(e) { if (e) e.stopPropagation() // Impede que o clique feche imediatamente o dropdown const dropdown = document.getElementById(DROPDOWN_ID) if (dropdown) { const isHidden = dropdown.style.display === 'none' if (isHidden) { // Repopula o dropdown caso modelos tenham sido adicionados/removidos ou para atualizar link de reset populateDropdown(dropdown) showElement(DROPDOWN_ID) } else { hideElement(DROPDOWN_ID) } } } /** * Fecha o dropdown se o clique ocorrer fora dele ou do botão 'S'. * @param {Event} event - O objeto do evento de clique. */ function handleOutsideClick(event) { const dropdown = document.getElementById(DROPDOWN_ID) const button = document.getElementById(BUTTON_ID) // Verifica se o dropdown está visível e se o clique foi fora dele e fora do botão if (dropdown && dropdown.style.display !== 'none' && !dropdown.contains(event.target) && !button?.contains(event.target)) { // Verifica se o botão existe hideElement(DROPDOWN_ID) } } /** * Exibe o overlay de sumarização com o conteúdo fornecido. * Cria o overlay se ele não existir. * Simplificado: O botão retry apenas chama processSummarization. * @param {string} contentHTML - O conteúdo HTML a ser exibido (pode ser loading, sumário ou erro com retry). * @param {boolean} [isError=false] - Indica se o conteúdo é uma mensagem de erro para adicionar botão de retry. */ function showSummaryOverlay(contentHTML, isError = false) { // Se o overlay já existe, apenas atualiza o conteúdo if (document.getElementById(OVERLAY_ID)) { updateSummaryOverlay(contentHTML, isError) return } // Cria o elemento do overlay const overlay = document.createElement('div') overlay.id = OVERLAY_ID // Define o HTML interno com container, botão de fechar e conteúdo inicial let finalContent = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}` // Adiciona botão de Tentar Novamente se for um erro if (isError) { finalContent += `<button id="${RETRY_BUTTON_ID}" class="retry-button">Try Again</button>` } overlay.innerHTML = `<div id="${CONTENT_ID}">${finalContent}</div>` document.body.appendChild(overlay) document.body.style.overflow = 'hidden' // Trava o scroll do body // Adiciona listeners para fechar o overlay document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay) overlay.addEventListener('click', (e) => { // Fecha clicando no fundo (fora do content) if (e.target === overlay) closeOverlay() }) // Adiciona listener para o botão de Tentar Novamente, se existir // Apenas chama processSummarization() novamente document.getElementById(RETRY_BUTTON_ID)?.addEventListener('click', processSummarization) } /** * Fecha e remove o overlay de sumarização do DOM. Restaura o scroll do body. */ function closeOverlay() { const overlay = document.getElementById(OVERLAY_ID) if (overlay) { overlay.remove() document.body.style.overflow = '' // Libera o scroll do body } } /** * Atualiza o conteúdo dentro do overlay de sumarização já existente. * Simplificado: O botão retry apenas chama processSummarization. * @param {string} contentHTML - O novo conteúdo HTML. * @param {boolean} [isError=false] - Indica se o conteúdo é uma mensagem de erro para adicionar botão de retry. */ function updateSummaryOverlay(contentHTML, isError = false) { const contentDiv = document.getElementById(CONTENT_ID) if (contentDiv) { // Recria o conteúdo interno, garantindo que o botão de fechar permaneça let finalContent = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}` // Adiciona botão de Tentar Novamente se for um erro if (isError) { finalContent += `<button id="${RETRY_BUTTON_ID}" class="retry-button">Try Again</button>` } contentDiv.innerHTML = finalContent // Reatribui o listener ao novo botão de fechar document.getElementById(CLOSE_BUTTON_ID)?.addEventListener('click', closeOverlay) // Reatribui listener ao botão de Tentar Novamente, se existir // Apenas chama processSummarization() novamente document.getElementById(RETRY_BUTTON_ID)?.addEventListener('click', processSummarization) } } /** * Exibe uma notificação de erro temporária na parte inferior central da tela. * @param {string} message - A mensagem de erro. */ function showErrorNotification(message) { document.getElementById(ERROR_ID)?.remove() // Remove notificação anterior const errorDiv = document.createElement('div') errorDiv.id = ERROR_ID errorDiv.innerText = message document.body.appendChild(errorDiv) // Remove a notificação após 4 segundos setTimeout(() => errorDiv.remove(), 4000) } /** * Esconde um elemento pelo seu ID, definindo display como 'none'. * @param {string} id - O ID do elemento. */ function hideElement(id) { const el = document.getElementById(id) if (el) el.style.display = 'none' } /** * Mostra um elemento pelo seu ID. * @param {string} id - O ID do elemento. */ function showElement(id) { const el = document.getElementById(id) if (el) { // Usa 'flex' para o botão e 'block' para os outros por padrão el.style.display = (id === BUTTON_ID) ? 'flex' : 'block' } } // --- Funções de Lógica (Sumarização, API, Modelos) --- /** * Encontra o objeto de configuração completo para o modelo ativo (padrão ou customizado). * @returns {object|null} Um objeto contendo { id, service, name?, params? } ou null se não encontrado. */ function getActiveModelConfig() { for (const service in MODEL_GROUPS) { const group = MODEL_GROUPS[service] const modelConfig = group.models.find(m => m.id === activeModel) if (modelConfig) { // Retorna uma cópia do objeto, adicionando a chave do serviço return {...modelConfig, service: service} } } // Verifica modelos customizados const customConfig = customModels.find(m => m.id === activeModel) if (customConfig) { // Custom models não tem 'name' ou 'params' definidos por padrão aqui // Retorna uma cópia do objeto customizado { id, service } return {...customConfig} } console.error(`Summarize with AI: Active model configuration not found for ID: ${activeModel}`) return null // Modelo não encontrado } /** * Orquestra o processo de sumarização: obtém API key, mostra overlay de loading com nome do modelo, * envia requisição à API e trata a resposta. */ async function processSummarization() { try { // Garante que temos dados do artigo antes de prosseguir if (!articleData) { showErrorNotification('Article content not found or not readable.') return } const modelConfig = getActiveModelConfig() // Obtém a configuração completa do modelo ativo if (!modelConfig) { // Mensagem de erro mais informativa se o modelo não for encontrado showErrorNotification(`Configuration for model "${activeModel}" not found. Please select another model.`) return // Interrompe a execução se a configuração não for encontrada } // Determina o nome a ser exibido (usa 'name' se disponível, senão 'id') const modelDisplayName = modelConfig.name || modelConfig.id const service = modelConfig.service // Determina 'openai' ou 'gemini' a partir da config const apiKey = await getApiKey(service) // Obtém a API key (pede ao usuário se não tiver) if (!apiKey) { // Aborta se não houver API key // Mostra erro no overlay se estiver aberto, senão como notificação const errorMsg = `API key for ${service.toUpperCase()} is required. Click the 'Reset Key' link in the model selection menu (long-press 'S' button).` if (document.getElementById(OVERLAY_ID)) { // Mostra o erro no overlay existente, sem botão de retry para este caso updateSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`, false) } else { // Se o overlay não estava aberto, mostra como notificação showErrorNotification(errorMsg) } return // Interrompe se não houver chave } // Mostra feedback de loading com o nome do modelo // Verifica se o overlay já existe (caso seja um retry) const loadingMessage = `<p class="glow">Summarizing with ${modelDisplayName}... </p>` if (document.getElementById(OVERLAY_ID)) { updateSummaryOverlay(loadingMessage) // Atualiza overlay existente } else { showSummaryOverlay(loadingMessage) // Cria novo overlay } // Prepara os dados para a API const payload = {title: articleData.title, content: articleData.content, lang: navigator.language || 'en-US'} // Passa a configuração do modelo para sendApiRequest const response = await sendApiRequest(service, apiKey, payload, modelConfig) handleApiResponse(response, service) // Processa a resposta } catch (error) { // Exibe erros no overlay com botão de Tentar Novamente const errorMsg = `Error: ${error.message}` console.error('Summarize with AI:', errorMsg, error) // Loga o erro completo // Mostra erro no overlay (ou cria um novo se não existir), com botão de retry showSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`, true) hideElement(DROPDOWN_ID) // Garante que o dropdown esteja oculto em caso de erro } } /** * Envia a requisição HTTP para a API de IA (OpenAI ou Gemini). * @param {string} service - 'openai' ou 'gemini'. * @param {string} apiKey - A chave da API para o serviço. * @param {object} payload - Objeto com { title, content, lang }. * @param {object} modelConfig - Configuração do modelo ativo { id, service, name?, params? }. * @returns {Promise<object>} - A promessa resolve com o objeto de resposta da requisição. */ async function sendApiRequest(service, apiKey, payload, modelConfig) { const group = MODEL_GROUPS[service] const url = service === 'openai' ? group.baseUrl // URL base da OpenAI : `${group.baseUrl}${modelConfig.id}:generateContent?key=${apiKey}` // URL Gemini (inclui ID do modelo e key) return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: 'POST', url: url, headers: getHeaders(service, apiKey), // Cabeçalhos específicos do serviço // Passa modelConfig para construir o corpo corretamente data: JSON.stringify(buildRequestBody(service, payload, modelConfig)), responseType: 'json', // Espera uma resposta JSON timeout: 60000, // Timeout de 60 segundos onload: response => { // GM.xmlHttpRequest pode retornar response.response em vez de responseText para JSON const responseData = response.response || response.responseText // Resolve com um objeto contendo status e dados parseados (ou texto original) resolve({ status: response.status, // Tenta parsear mesmo que responseType seja json, pois pode falhar data: typeof responseData === 'object' ? responseData : JSON.parse(responseData || '{}'), statusText: response.statusText, }) }, onerror: error => reject(new Error(`Network error: ${error.statusText || 'Failed to connect'}`)), onabort: () => reject(new Error('Request aborted')), ontimeout: () => reject(new Error('Request timed out')), }) }) } /** * Processa a resposta da API, extrai o sumário, limpa quebras de linha extras e atualiza o overlay. * @param {object} response - O objeto de resposta resolvido da Promise de `sendApiRequest` (contém status, data). * @param {string} service - 'openai' ou 'gemini'. */ function handleApiResponse(response, service) { const {status, data, statusText} = response // Verifica se o status HTTP indica sucesso (2xx) if (status < 200 || status >= 300) { // Tenta extrair uma mensagem de erro mais detalhada do corpo da resposta const errorDetails = data?.error?.message || data?.message || statusText || 'Unknown API error' // Gemini pode usar 'message' no erro throw new Error(`API Error (${status}): ${errorDetails}`) } // Extrai o conteúdo do sumário dependendo do serviço let rawSummary = '' if (service === 'openai') { const choice = data?.choices?.[0] rawSummary = choice?.message?.content // Loga o motivo pelo qual a geração parou const finishReason = choice?.finish_reason console.log(`Summarize with AI: OpenAI Finish Reason: ${finishReason} (Model: ${activeModel})`) if (finishReason === 'length') { console.warn('Summarize with AI: Summary may be incomplete because the max token limit was reached.') } } else if (service === 'gemini') { const candidate = data?.candidates?.[0] const finishReason = candidate?.finishReason console.log(`Summarize with AI: Gemini Finish Reason: ${finishReason} (Model: ${activeModel})`) if (finishReason === 'SAFETY') { const safetyRatings = candidate.safetyRatings?.map(r => `${r.category}: ${r.probability}`).join(', ') throw new Error(`Content blocked due to safety concerns (${safetyRatings || 'No details'}).`) } if (finishReason === 'MAX_TOKENS') { console.warn('Summarize with AI: Summary may be incomplete because the max token limit was reached.') } // Verificação robusta: garante que parts existe e tem conteúdo if (candidate?.content?.parts?.length > 0 && candidate.content.parts[0].text) { rawSummary = candidate.content.parts[0].text } else if (finishReason && !['STOP', 'SAFETY', 'MAX_TOKENS'].includes(finishReason)) { // Loga aviso se motivo de finalização não for comum e não houver texto console.warn(`Summarize with AI: Gemini response structure missing expected text content or unusual finish reason: ${finishReason}`, candidate) } else if (!rawSummary && !data?.error) { console.warn('Summarize with AI: Gemini response structure missing expected text content.', candidate) } // Se rawSummary ainda estiver vazio aqui, o erro "did not contain valid summary" será lançado abaixo } // Verifica se o sumário foi realmente obtido if (!rawSummary && !data?.error) { // Adicionada verificação !data?.error para não sobrescrever erros de API console.error('Summarize with AI: API Response Data:', data) // Loga a resposta para depuração throw new Error('API response did not contain a valid summary.') } // Limpa quebras de linha (\n) que não fazem parte de tags HTML (substitui por espaço) // e comprime múltiplos espaços em um único espaço. // Isso ajuda a evitar espaçamento duplo estranho se a API retornar \n desnecessários. const cleanedSummary = rawSummary.replace(/\n/g, ' ').replace(/ {2,}/g, ' ').trim() // Atualiza o overlay com o sumário limpo, sem botão de retry updateSummaryOverlay(cleanedSummary, false) } /** * Constrói o corpo (payload) da requisição para a API (OpenAI ou Gemini). * Usa parâmetros definidos no modelConfig ou os padrões do serviço. * @param {string} service - 'openai' ou 'gemini'. * @param {object} payload - Objeto com { title, content, lang }. * @param {object} modelConfig - Configuração do modelo ativo { id, service, name?, params? }. * @returns {object} - O objeto do corpo da requisição. */ function buildRequestBody(service, {title, content, lang}, modelConfig) { const systemPrompt = PROMPT_TEMPLATE(title, content, lang) // Gera o prompt principal const serviceDefaults = MODEL_GROUPS[service]?.defaultParams || {} const modelSpecificParams = modelConfig?.params || {} if (service === 'openai') { // Mescla parâmetros padrão e específicos do modelo const finalParams = {...serviceDefaults, ...modelSpecificParams} return { model: modelConfig.id, // Usa o ID do modelo da config messages: [ {role: 'system', content: systemPrompt}, {role: 'user', content: 'Generate the summary as requested.'}, ], // Inclui parâmetros mesclados (ex: max_completion_tokens) ...finalParams // 'temperature' não está definido, usará o padrão da API ou o definido em params } } else { // gemini // Mescla parâmetros padrão e específicos do modelo para generationConfig const finalGenConfigParams = {...serviceDefaults, ...modelSpecificParams} return { contents: [{ parts: [{text: systemPrompt}], // Estrutura do Gemini }], // Inclui generationConfig com parâmetros mesclados generationConfig: finalGenConfigParams // 'temperature' não está definido, usará o padrão da API ou o definido em params } } } /** * Retorna os cabeçalhos HTTP apropriados para a API. * @param {string} service - 'openai' ou 'gemini'. * @param {string} apiKey - A chave da API. * @returns {object} - O objeto de cabeçalhos. */ function getHeaders(service, apiKey) { const headers = {'Content-Type': 'application/json'} if (service === 'openai') { headers['Authorization'] = `Bearer ${apiKey}` // OpenAI usa Bearer token } // Gemini usa a chave na URL, não no cabeçalho return headers } /** * Obtém a chave da API para o serviço especificado a partir do armazenamento (GM.getValue). * Se não existir, retorna null (a verificação e mensagem de erro ocorrem em processSummarization). * @param {string} service - 'openai' ou 'gemini'. * @returns {Promise<string|null>} - A chave da API ou null se não for encontrada. */ async function getApiKey(service) { const storageKey = `${service}_api_key` let apiKey = await GM.getValue(storageKey) // Retorna a chave encontrada ou null se não existir/vazia return apiKey?.trim() || null } /** * Permite ao usuário resetar (redefinir) a chave da API para um serviço específico via prompt. * Ativado pelo link 'Reset Key' no dropdown. * @param {string} service - O serviço ('openai' ou 'gemini') para o qual resetar a chave. */ async function handleApiKeyReset(service) { if (!service || !MODEL_GROUPS[service]) { console.error("Invalid service provided for API key reset:", service) alert("Internal error: Invalid service provided.") return } const storageKey = `${service}_api_key` const newKey = prompt(`Enter the new ${service.toUpperCase()} API key (leave blank to clear):`) if (newKey !== null) { // Verifica se o usuário não cancelou (clicou em OK ou deixou em branco) const keyToSave = newKey.trim() await GM.setValue(storageKey, keyToSave) if (keyToSave) { alert(`${service.toUpperCase()} API key updated!`) } else { alert(`${service.toUpperCase()} API key cleared!`) } // Opcional: Repopular dropdown para refletir alguma mudança visual se necessário // const dropdown = document.getElementById(DROPDOWN_ID) // if (dropdown && dropdown.style.display !== 'none') { // populateDropdown(dropdown) // } } // Se newKey for null (usuário clicou Cancelar), não faz nada. } /** * Gerencia o fluxo para adicionar um novo modelo customizado. * Pede ao usuário o serviço e o ID do modelo via prompts. * Salva no formato { id, service }. */ async function handleAddModel() { // 1. Pergunta o serviço (OpenAI ou Gemini) const service = prompt('Enter the service for the custom model (openai / gemini):')?.toLowerCase()?.trim() if (!service || !MODEL_GROUPS[service]) { if (service !== null) alert('Invalid service. Please enter "openai" or "gemini".') return // Cancela se inválido ou se o usuário cancelar } // 2. Pergunta o nome exato (ID) do modelo const modelId = prompt(`Enter the exact ID of the ${service.toUpperCase()} model:`)?.trim() if (!modelId) { if (modelId !== null) alert('Model ID cannot be empty.') return // Cancela se vazio ou se o usuário cancelar } // 3. Adiciona o modelo e salva await addCustomModel(service, modelId) // Opcional: reabrir dropdown após adicionar? Por ora, não. } /** * Adiciona um novo modelo customizado à lista e salva no GM storage. * Atualiza a variável global `customModels`. Salva como { id, service }. * @param {string} service - 'openai' ou 'gemini'. * @param {string} modelId - O ID exato do modelo. */ async function addCustomModel(service, modelId) { // Verifica se o ID do modelo já existe para este serviço (case-insensitive) const exists = customModels.some(m => m.service === service && m.id.toLowerCase() === modelId.toLowerCase()) || MODEL_GROUPS[service]?.models.some(m => m.id.toLowerCase() === modelId.toLowerCase()) // Verifica também nos padrões if (exists) { alert(`Model ID "${modelId}" already exists for ${service.toUpperCase()}.`) return } // Adiciona o novo modelo à lista na memória customModels.push({id: modelId, service}) // Salva no formato { id, service } // Salva a lista atualizada no storage await GM.setValue(CUSTOM_MODELS_KEY, JSON.stringify(customModels)) alert(`Custom model "${modelId}" (${service.toUpperCase()}) added!`) } /** * Carrega a lista de modelos customizados salvos no GM storage. * Espera o formato [{ id, service }, ...]. * @returns {Promise<Array<object>>} - Uma promessa que resolve com o array de modelos customizados. */ async function getCustomModels() { try { const storedModels = await GM.getValue(CUSTOM_MODELS_KEY, '[]') // Obtém a string JSON, default '[]' const parsedModels = JSON.parse(storedModels) // Validação simples para garantir que é um array de objetos com id e service if (Array.isArray(parsedModels) && parsedModels.every(m => typeof m === 'object' && m.id && m.service)) { return parsedModels } else { console.warn("Summarize with AI: Invalid custom model format found in storage. Resetting.", parsedModels) await GM.setValue(CUSTOM_MODELS_KEY, '[]') // Reseta se formato inválido return [] } } catch (error) { console.error('Summarize with AI: Failed to load/parse custom models:', error) // Em caso de erro de parse, retorna um array vazio e tenta limpar o storage await GM.setValue(CUSTOM_MODELS_KEY, '[]') // Reseta para um array vazio return [] } } // --- Funções de Eventos e Utilidades --- /** * Manipulador para o atalho de teclado (Alt+S) e tecla Esc. * Alt+S: Simula clique no botão 'S' (inicia sumarização). * Esc: Fecha o overlay ou o dropdown. * @param {KeyboardEvent} e - O objeto do evento de teclado. */ function handleKeyPress(e) { // Atalho Alt+S para iniciar sumarização (simula clique simples) if (e.altKey && e.code === 'KeyS') { e.preventDefault() const button = document.getElementById(BUTTON_ID) if (button) { // Chama a função principal de sumarização processSummarization() } } // Tecla Esc para fechar overlay ou dropdown if (e.key === 'Escape') { if (document.getElementById(OVERLAY_ID)) { // Prioriza fechar o overlay closeOverlay() } else if (document.getElementById(DROPDOWN_ID)?.style.display !== 'none') { // Fecha o dropdown se aberto hideElement(DROPDOWN_ID) } } } /** * Configura listeners para esconder/mostrar o botão 'S' com base no foco em inputs. */ function setupFocusListeners() { // Esconde o botão quando um campo editável ganha foco document.addEventListener('focusin', (event) => { if (event.target?.closest('input, textarea, select, [contenteditable="true"]')) { hideElement(BUTTON_ID) hideElement(DROPDOWN_ID) // Esconde dropdown também } }) // Mostra o botão quando o foco sai de um campo editável (clicando fora) document.addEventListener('focusout', (event) => { // Verifica se o elemento que perdeu o foco é um campo editável // e se o novo elemento focado (relatedTarget) NÃO é um campo editável const isLeavingInput = event.target?.closest('input, textarea, select, [contenteditable="true"]') const isEnteringInput = event.relatedTarget?.closest('input, textarea, select, [contenteditable="true"]') // Só mostra o botão se estiver saindo de um input e não entrando em outro, // e se o artigo foi detectado. if (isLeavingInput && !isEnteringInput && articleData) { // Pequeno delay para evitar piscar se o foco mudar rapidamente entre inputs setTimeout(() => { // Reconfirma se o foco atual não é um input antes de mostrar if (!document.activeElement?.closest('input, textarea, select, [contenteditable="true"]')) { showElement(BUTTON_ID) } }, 50) // Delay de 50ms } }, true) // Usa captura para garantir que o evento seja pego } /** * Injeta os estilos CSS necessários para a interface do script. */ function injectStyles() { // Estilos CSS com adições para cores de qualidade, dark mode e responsividade móvel GM.addStyle(` /* --- Elementos Principais da UI --- */ #${BUTTON_ID} { position: fixed; bottom: 20px; right: 20px; width: 50px; height: 50px; /* Tamanho */ background: linear-gradient(145deg, #3a7bd5, #00d2ff); /* Gradiente azul */ color: white; font-size: 24px; /* Texto */ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; border-radius: 50%; cursor: pointer; z-index: 2147483640; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); display: flex !important; align-items: center !important; justify-content: center !important; /* Centraliza 'S' */ transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; line-height: 1; user-select: none; /* Previne seleção */ } #${BUTTON_ID}:hover { transform: scale(1.1); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25); } #${DROPDOWN_ID} { position: fixed; bottom: 80px; right: 20px; /* Acima do botão */ background: #ffffff; border: 1px solid #e0e0e0; border-radius: 10px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); z-index: 2147483641; /* Acima do botão */ max-height: 70vh; overflow-y: auto; /* Scroll */ padding: 8px; width: 300px; /* Dimensões */ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; display: none; /* Começa oculto */ animation: fadeIn 0.2s ease-out; /* Animação */ } #${OVERLAY_ID} { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); /* Fundo semi-transparente (padrão light) */ z-index: 2147483645; /* Muito alto */ display: flex; align-items: center; justify-content: center; overflow: hidden; /* Impede scroll do body */ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; animation: fadeIn 0.3s ease-out; } #${CONTENT_ID} { background-color: #fff; /* Fundo branco (padrão light) */ color: #333; /* Texto escuro (padrão light) */ padding: 25px 35px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); max-width: 800px; width: 90%; max-height: 85vh; /* Dimensões */ overflow-y: auto; /* Scroll interno */ position: relative; font-size: 16px; line-height: 1.6; animation: slideInUp 0.3s ease-out; /* Animação */ white-space: normal; /* Permite quebra de linha HTML */ box-sizing: border-box; /* Garante que padding não aumente o tamanho total */ } #${CONTENT_ID} p { margin-top: 0; margin-bottom: 1em; } /* Margem padrão para parágrafos */ #${CONTENT_ID} ul { margin: 1em 0; padding-left: 1.5em; } /* Adiciona padding para bullet points */ #${CONTENT_ID} li { list-style-type: none; margin-bottom: 0.5em; } /* Remove marcador padrão (usa emoji) */ #${CLOSE_BUTTON_ID} { position: absolute; top: 10px; right: 15px; font-size: 28px; color: #aaa; /* Cinza claro (padrão light) */ cursor: pointer; transition: color 0.2s; line-height: 1; z-index: 1; /* Garante que fique acima do conteúdo */ } #${CLOSE_BUTTON_ID}:hover { color: #333; } /* Mais escuro no hover (light) */ #${ERROR_ID} { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); /* Centralizado */ background-color: #e53e3e; color: white; padding: 12px 20px; border-radius: 6px; z-index: 2147483646; /* Acima de tudo */ font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; animation: fadeIn 0.3s, fadeOut 0.3s 3.7s forwards; /* Fade in e out */ } .retry-button { /* Estilo para o botão Tentar Novamente */ display: block; margin: 20px auto 0; padding: 8px 16px; background-color: #4a90e2; /* Azul (padrão light) */ color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; transition: background-color 0.2s; } .retry-button:hover { background-color: #3a7bd5; } /* Azul mais escuro no hover (light) */ /* --- Estilos do Dropdown --- */ .model-group { margin-bottom: 8px; } .group-header-container { /* Container para header e link reset */ display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #f7f7f7; border-radius: 6px; margin-bottom: 4px; } .group-header-text { /* Texto do header */ font-weight: 600; color: #333; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; flex-grow: 1; /* Ocupa espaço disponível */ } .reset-key-link { /* Link de reset */ font-size: 11px; color: #666; text-decoration: none; margin-left: 10px; /* Espaçamento */ white-space: nowrap; /* Não quebrar linha */ cursor: pointer; transition: color 0.2s; } .reset-key-link:hover { color: #1a73e8; } .model-item { padding: 10px 14px; margin: 2px 0; border-radius: 6px; transition: background-color 0.15s ease-out, color 0.15s ease-out; font-size: 14px; cursor: pointer; color: #444; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; /* Evita quebra de linha em nomes longos */ } .model-item:hover { background-color: #eef6ff; color: #1a73e8; } .add-model-item { /* Estilo específico para o item de adicionar modelo */ color: #666; font-style: italic; } .add-model-item:hover { background-color: #f0f0f0; color: #333; } /* --- Estilos de Conteúdo (Glow, Qualidade) --- */ .glow { /* Estilo para "Summarizing with [Model]..." / "Retrying with [Model]..." */ font-size: 1.4em; text-align: center; padding: 40px 0; /* Aplica a animação 'glow' com ciclo infinito e duração de 2.5s */ animation: glow 2.5s ease-in-out infinite; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-weight: 400; } /* Cores para as classes de qualidade do artigo */ span.article-excellent { color: #2ecc71; font-weight: bold; } /* Verde brilhante */ span.article-good { color: #3498db; font-weight: bold; } /* Azul */ span.article-average { color: #f39c12; font-weight: bold; } /* Laranja */ span.article-bad { color: #e74c3c; font-weight: bold; } /* Vermelho */ span.article-very-bad { color: #c0392b; font-weight: bold; } /* Vermelho escuro */ /* --- Animações --- */ /* Define os keyframes para a animação 'glow' ciclando entre azul, roxo e vermelho */ @keyframes glow { 0%, 100% { /* Início e Fim: Azul */ color: #4a90e2; text-shadow: 0 0 10px rgba(74, 144, 226, 0.6), 0 0 20px rgba(74, 144, 226, 0.4); } 33% { /* Ponto intermediário 1: Roxo */ color: #9b59b6; /* Tom de roxo */ text-shadow: 0 0 12px rgba(155, 89, 182, 0.7), /* Sombra roxa */ 0 0 25px rgba(155, 89, 182, 0.5); } 66% { /* Ponto intermediário 2: Vermelho */ color: #e74c3c; /* Tom de vermelho */ text-shadow: 0 0 12px rgba(231, 76, 60, 0.7), /* Sombra vermelha */ 0 0 25px rgba(231, 76, 60, 0.5); } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } @keyframes slideInUp { from { transform: translateY(30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } /* --- Dark Mode Override (Adaptação automática ao tema escuro do sistema) --- */ @media (prefers-color-scheme: dark) { /* Fundo do overlay mais escuro */ #${OVERLAY_ID} { background-color: rgba(20, 20, 20, 0.7); /* Fundo mais opaco e escuro */ } /* Conteúdo do sumário com fundo escuro e texto claro */ #${CONTENT_ID} { background-color: #2c2c2c; /* Cinza bem escuro */ color: #e0e0e0; /* Texto cinza claro */ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); /* Sombra um pouco mais visível */ } /* Botão de fechar com cores invertidas */ #${CLOSE_BUTTON_ID} { color: #888; /* Cinza médio */ } #${CLOSE_BUTTON_ID}:hover { color: #eee; /* Quase branco no hover */ } /* Botão Tentar Novamente com estilo adaptado */ .retry-button { background-color: #555; /* Cinza médio */ color: #eee; /* Texto claro */ } .retry-button:hover { background-color: #666; /* Cinza um pouco mais claro no hover */ } /* Dropdown também pode ter fundo escuro (opcional, mantendo legibilidade) */ #${DROPDOWN_ID} { background: #333; /* Fundo escuro para dropdown */ border-color: #555; /* Borda mais escura */ } .model-item { color: #ccc; /* Texto do item mais claro */ } .model-item:hover { background-color: #444; /* Fundo de hover mais escuro */ color: #fff; /* Texto branco no hover */ } .group-header-container { background: #444; /* Fundo do cabeçalho do grupo */ } .group-header-text { color: #eee; /* Texto do cabeçalho claro */ } .reset-key-link { color: #aaa; /* Link de reset mais claro */ } .reset-key-link:hover { color: #fff; /* Link de reset branco no hover */ } .add-model-item { color: #999; /* Item de adicionar mais claro */ } .add-model-item:hover { background-color: #4a4a4a; /* Fundo de hover */ color: #eee; /* Texto claro no hover */ } hr { border-top-color: #555 !important; /* Separador mais escuro */ } /* Ajuste de cor para o brilho no modo escuro se necessário (opcional) */ /* As cores atuais do glow parecem funcionar bem, mas podem ser ajustadas aqui */ /* @keyframes glow-dark { ... } */ /* .glow { animation-name: glow-dark; } */ } /* --- Mobile Responsiveness --- */ /* Ajustes para telas pequenas (e.g., smartphones) */ @media (max-width: 600px) { /* Faz o conteúdo do overlay ocupar a tela inteira */ #${CONTENT_ID} { width: 100%; /* Largura total */ height: 100%; /* Altura total */ max-width: none; /* Remove limite de largura máxima */ max-height: none; /* Remove limite de altura máxima */ border-radius: 0; /* Remove cantos arredondados (edge-to-edge) */ padding: 15px; /* Reduz padding interno */ box-shadow: none; /* Remove sombra (opcional) */ /* A animação pode ser desabilitada em mobile se preferir */ /* animation: none; */ } /* Ajusta posição do botão de fechar para o novo padding */ #${CLOSE_BUTTON_ID} { top: 10px; right: 10px; } /* Esconde o botão flutuante 'S' e o dropdown quando o overlay estiver aberto */ /* (Embora o overlay já esteja acima, garante que não apareçam por baixo) */ #${OVERLAY_ID} ~ #${BUTTON_ID}, #${OVERLAY_ID} ~ #${DROPDOWN_ID} { display: none !important; } } `) } // --- Inicialização --- initialize() // Chama a função principal para iniciar o script })()