Summarize with AI (Unified)

Single-button AI summarization (OpenAI/Gemini) with model selection dropdown for articles/news. Uses Alt+S shortcut.

目前為 2025-05-01 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Summarize with AI (Unified)
// @namespace    https://github.com/insign/userscripts
// @version      2025.02.16.14.56
// @description  Single-button AI summarization (OpenAI/Gemini) with model selection dropdown for articles/news. Uses Alt+S shortcut.
// @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.5.0/Readability.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/readability/0.5.0/Readability-readerable.min.js
// ==/UserScript==

(function() {
	'use strict'

	// IDs dos elementos da interface do script
	const BUTTON_ID       = 'summarize-button'       // Botão principal flutuante
	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

	// Configuração dos serviços e modelos de IA suportados
	const MODEL_GROUPS = {
		openai: {
			name   : 'OpenAI',                           // Nome do serviço
			models : ['gpt-4o-mini', 'o3-mini'], // Modelos disponíveis
			baseUrl: 'https://api.openai.com/v1/chat/completions', // URL base da API
		},
		gemini: {
			name   : 'Gemini',
			models : [                                   // Modelos Gemini disponíveis
				'gemini-2.0-flash-exp', 'gemini-2.0-pro-exp-02-05', 'gemini-2.0-flash-thinking-exp-01-21', 'learnlm-1.5-pro-experimental',
				'gemini-2.0-flash-lite-preview-02-05',
			],
			baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models/', // URL base da API Gemini
		},
	}

	// Template do prompt enviado para a IA
	// Inclui instruções sobre o formato desejado (HTML, introdução, bullets, conclusão) e o idioma.
	const PROMPT_TEMPLATE = (title, content, lang) => `You are a helpful assistant that provides clear and affirmative explanations of content.
Generate a concise summary that includes:
- 2-sentence introduction
- Bullet points with relevant emojis
- No section headers
- Use HTML formatting, but send without \`\`\`html markdown syntax since it will be injected into the page to the browser evaluate correctly
- After the last bullet point add a 2-sentence conclusion using opinionated language based on your general knowledge
- Language: ${lang}

Article Title: ${title}
Article Content: ${content}`

	// Variáveis de estado
	let activeModel = 'gpt-4o-mini' // Modelo selecionado por padrão ou pelo usuário
	let articleData = null          // Armazena o título e conteúdo extraído do artigo

	/**
	 * Função principal de inicialização do script.
	 * Adiciona listener de teclado, tenta extrair dados do artigo,
	 * e se bem-sucedido, adiciona o botão e listeners de foco.
	 */
	function initialize() {
		document.addEventListener('keydown', handleKeyPress) // Listener para o atalho Alt+S
		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
		}
	}

	/**
	 * 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 {
			// Clona o documento para não modificar o original
			const docClone = document.cloneNode(true)
			// Remove scripts e estilos do clone para evitar interferências com Readability
			docClone.querySelectorAll('script, style, noscript, iframe, figure, img').forEach(el => el.remove())
			// Verifica se a página é provavelmente legível antes de tentar parsear
			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 os dados se o conteúdo foi extraído com sucesso
			return article?.content ? { 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.
	 */
	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, mantido pequeno
		document.body.appendChild(button)

		// Cria o dropdown (inicialmente oculto)
		const dropdown = createDropdown()
		document.body.appendChild(dropdown)

		// Listener para clique simples: mostra/esconde o dropdown
		button.addEventListener('click', toggleDropdown)
		// Listener para duplo clique: permite resetar a chave da API
		button.addEventListener('dblclick', handleApiKeyReset)

		// Injeta os estilos CSS necessários para a interface
		injectStyles()
	}

	/**
	 * Cria o elemento do dropdown com os grupos de modelos.
	 * @returns {HTMLElement} - O elemento div do dropdown.
	 */
	function createDropdown() {
		const dropdown         = document.createElement('div')
		dropdown.id            = DROPDOWN_ID
		dropdown.style.display = 'none' // Começa oculto

		// Itera sobre os grupos de modelos (OpenAI, Gemini)
		Object.entries(MODEL_GROUPS).forEach(([service, group]) => {
			const groupDiv     = document.createElement('div')
			groupDiv.className = 'model-group'
			// Adiciona o cabeçalho do grupo (e.g., "OpenAI")
			groupDiv.appendChild(createHeader(group.name))
			// Adiciona cada item de modelo dentro do grupo
			group.models.forEach(model => groupDiv.appendChild(createModelItem(model)))
			dropdown.appendChild(groupDiv) // Adiciona o grupo ao dropdown
		})
		return dropdown
	}

	/**
	 * Cria um elemento de cabeçalho para um grupo de modelos no dropdown.
	 * @param {string} text - O texto do cabeçalho (nome do serviço).
	 * @returns {HTMLElement} - O elemento div do cabeçalho.
	 */
	function createHeader(text) {
		const header       = document.createElement('div')
		header.className   = 'group-header'
		header.textContent = text
		return header
	}

	/**
	 * Cria um item clicável para um modelo específico no dropdown.
	 * @param {string} model - O nome do modelo.
	 * @returns {HTMLElement} - O elemento div do item do modelo.
	 */
	function createModelItem(model) {
		const item       = document.createElement('div')
		item.className   = 'model-item'
		item.textContent = model
		// Listener de clique: seleciona o modelo, esconde dropdown e inicia sumarização
		item.addEventListener('click', () => {
			activeModel = model               // Define o modelo ativo
			hideElement(DROPDOWN_ID)        // Esconde o dropdown
			processSummarization()          // Inicia o processo de sumarização
		})
		return item
	}

	/**
	 * Orquestra o processo de sumarização: obtém API key, mostra overlay de loading,
	 * envia requisição à API e trata a resposta.
	 */
	async function processSummarization() {
		try {
			const service = getCurrentService() // Determina qual serviço (openai/gemini) usar com base no `activeModel`
			const apiKey  = await getApiKey(service) // Obtém a API key (pede ao usuário se não tiver)

			// Aborta se não houver API key
			if (!apiKey) {
				showErrorNotification(`API key for ${service.toUpperCase()} is required. Double-click the 'S' button to set it.`)
				return
			}

			// Mostra o overlay com mensagem de "Summarizing..."
			showSummaryOverlay('<p class="glow">Summarizing...</p>')

			// Prepara os dados para a API
			const payload = { title: articleData.title, content: articleData.content, lang: navigator.language || 'en-US' }

			// Envia a requisição para a API apropriada
			const response = await sendApiRequest(service, apiKey, payload)

			// Processa a resposta da API
			handleApiResponse(response, service)

		} catch (error) {
			// Exibe erros no overlay ou como notificação
			const errorMsg = `Error: ${error.message}`
			console.error('Summarize with AI:', errorMsg)
			if (document.getElementById(OVERLAY_ID)) {
				updateSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`)
			} else {
				showErrorNotification(errorMsg)
			}
			// Garante que o dropdown esteja oculto em caso de erro durante o processamento
			hideElement(DROPDOWN_ID)
		}
	}

	/**
	 * Envia a requisição HTTP para a API de IA (OpenAI ou Gemini).
	 * Usa GM.xmlHttpRequest para contornar restrições de CORS.
	 * @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 }.
	 * @returns {Promise<object>} - A promessa resolve com o objeto de resposta da requisição.
	 */
	async function sendApiRequest(service, apiKey, payload) {
		// Monta a URL da API baseada no serviço e modelo ativo
		const url = service === 'openai'
				? MODEL_GROUPS.openai.baseUrl
				: `${MODEL_GROUPS.gemini.baseUrl}${activeModel}:generateContent?key=${apiKey}` // Gemini inclui a key na URL

		return new Promise((resolve, reject) => {
			GM.xmlHttpRequest({
				method : 'POST',
				url    : url,
				headers: getHeaders(service, apiKey),           // Obtém os cabeçalhos corretos para o serviço
				data   : JSON.stringify(buildRequestBody(service, payload)), // Constrói o corpo da requisição
				onload : response => resolve(response),        // Resolve a promessa com a resposta em caso de sucesso
				onerror: error => reject(new Error(`Network error: ${error.statusText || 'Failed to connect'}`)), // Rejeita em caso de erro de rede
				onabort: () => reject(new Error('Request aborted')), // Rejeita se a requisição for abortada
				ontimeout: () => reject(new Error('Request timed out')), // Rejeita em caso de timeout
			})
		})
	}

	/**
	 * Processa a resposta da API, extrai o sumário e atualiza o overlay.
	 * @param {object} response - O objeto de resposta da requisição (de GM.xmlHttpRequest).
	 * @param {string} service - 'openai' ou 'gemini'.
	 */
	function handleApiResponse(response, service) {
		// Verifica se o status da resposta HTTP é 200 (OK)
		if (response.status !== 200) {
			let errorDetails = response.statusText
			try {
				// Tenta extrair uma mensagem de erro mais detalhada do corpo da resposta JSON
				const errorData = JSON.parse(response.responseText)
				errorDetails = errorData?.error?.message || errorDetails
			} catch (e) { /* Ignora se não conseguir parsear o JSON do erro */ }
			throw new Error(`API Error (${response.status}): ${errorDetails}`)
		}

		// Parseia o corpo da resposta JSON
		const data = JSON.parse(response.responseText)

		// Extrai o conteúdo do sumário dependendo do serviço
		let summary = ''
		if (service === 'openai') {
			summary = data?.choices?.[0]?.message?.content
		} else if (service === 'gemini') {
			summary = data?.candidates?.[0]?.content?.parts?.[0]?.text
		}

		// Verifica se o sumário foi obtido
		if (!summary) {
			throw new Error('API response did not contain a valid summary.')
		}

		// Atualiza o overlay com o sumário formatado (substitui novas linhas por <br>)
		updateSummaryOverlay(summary.replace(/\n/g, '<br>'))
	}

	/**
	 * Constrói o corpo (payload) da requisição para a API, formatado corretamente
	 * para OpenAI ou Gemini.
	 * @param {string} service - 'openai' ou 'gemini'.
	 * @param {object} payload - Objeto com { title, content, lang }.
	 * @returns {object} - O objeto do corpo da requisição.
	 */
	function buildRequestBody(service, { title, content, lang }) {
		const systemPrompt = PROMPT_TEMPLATE(title, content, lang) // Gera o prompt do sistema

		if (service === 'openai') {
			return {
				model    : activeModel,
				messages : [
					{ role: 'system', content: systemPrompt },
					{ role: 'user', content: 'Generate the summary as requested.' }, // Mensagem curta do usuário
				],
				temperature: 0.5, // Controla a criatividade/determinismo
				max_tokens : 500, // Limita o tamanho da resposta
			}
		} else { // gemini
			return {
				contents: [
					{
						parts: [
							{ text: systemPrompt }, // Gemini usa uma estrutura diferente
						],
					},
				],
				// Configurações de geração podem ser adicionadas aqui se necessário (e suportado pelo modelo)
				// "generationConfig": { "temperature": 0.5, "maxOutputTokens": 500 }
			}
		}
	}

	/**
	 * Retorna os cabeçalhos HTTP apropriados para a requisição da API.
	 * OpenAI requer 'Authorization: Bearer <key>'. Gemini não (key na URL).
	 * @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}`
		}
		return headers
	}

	/**
	 * Determina qual serviço ('openai' ou 'gemini') corresponde ao `activeModel` atual.
	 * @returns {string|undefined} - O nome do serviço ou undefined se não encontrado.
	 */
	function getCurrentService() {
		// Encontra a chave (nome do serviço) cujo array `models` inclui o `activeModel`
		return Object.keys(MODEL_GROUPS).find(service => MODEL_GROUPS[service].models.includes(activeModel))
	}

	/**
	 * Mostra ou esconde o dropdown de seleção de modelo.
	 * @param {Event} e - O objeto do evento de clique.
	 */
	function toggleDropdown(e) {
		e.stopPropagation() // Impede que o clique se propague para o document (que fecharia o dropdown)
		const dropdown = document.getElementById(DROPDOWN_ID)
		if (dropdown) {
			dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'
		}
	}

	/**
	 * Manipulador para o atalho de teclado (Alt+S).
	 * Simula um clique no botão 'S' se ele existir.
	 * @param {KeyboardEvent} e - O objeto do evento de teclado.
	 */
	function handleKeyPress(e) {
		if (e.altKey && e.code === 'KeyS') { // Verifica se Alt+S foi pressionado
			e.preventDefault() // Previne a ação padrão do navegador (ex: abrir menu de histórico)
			const button = document.getElementById(BUTTON_ID)
			if (button) {
				// Se o dropdown estiver visível, esconde; senão, mostra (simula clique)
				const dropdown = document.getElementById(DROPDOWN_ID)
				if (dropdown && dropdown.style.display === 'block') {
					hideElement(DROPDOWN_ID)
				} else {
					button.click()
				}
			}
		}
		// Fecha o overlay ou dropdown com a tecla Esc
		if (e.key === 'Escape') {
			closeOverlay()
			hideElement(DROPDOWN_ID)
		}
	}

	/**
	 * Obtém a chave da API para o serviço especificado a partir do armazenamento (GM.getValue).
	 * Se não existir, pede ao usuário via prompt e armazena (GM.setValue).
	 * @param {string} service - 'openai' ou 'gemini'.
	 * @returns {Promise<string|null>} - A chave da API ou null se não for fornecida.
	 */
	async function getApiKey(service) {
		const storageKey = `${service}_api_key` // Chave usada para armazenar a API key
		let apiKey       = await GM.getValue(storageKey) // Tenta ler do armazenamento

		if (!apiKey) {
			// Pede ao usuário se não encontrou a chave
			apiKey = prompt(`Enter your ${service.toUpperCase()} API key:`)
			if (apiKey) {
				apiKey = apiKey.trim() // Remove espaços extras
				await GM.setValue(storageKey, apiKey) // Salva a chave fornecida
			} else {
				return null // Retorna null se o usuário cancelar ou não inserir nada
			}
		}
		return apiKey?.trim() // Retorna a chave (do armazenamento ou recém-inserida)
	}

	/**
	 * Permite ao usuário resetar (redefinir) a chave da API para um serviço.
	 * Ativado por duplo clique no botão 'S'.
	 */
	async function handleApiKeyReset() {
		// Pergunta para qual serviço resetar
		const service = prompt('Reset API key for which service? (openai / gemini)')?.toLowerCase()?.trim()

		if (service && MODEL_GROUPS[service]) { // Verifica se o serviço é válido
			// Pede a nova chave
			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
				await GM.setValue(`${service}_api_key`, newKey.trim()) // Salva a nova chave (ou string vazia para limpar)
				alert(`${service.toUpperCase()} API key updated!`)
			}
		} else if (service) {
			alert('Invalid service name. Please enter "openai" or "gemini".')
		}
	}

	/**
	 * Injeta os estilos CSS necessários para a interface do script na página.
	 * Usa GM.addStyle para adicionar os estilos.
	 */
	function injectStyles() {
		GM.addStyle(`
      #${BUTTON_ID} {
        position: fixed;
        bottom: 20px;
        right: 20px;
        width: 50px; /* Reduzido um pouco */
        height: 50px; /* Reduzido um pouco */
        background: linear-gradient(145deg, #3a7bd5, #00d2ff); /* Gradiente azul */
        color: white;
        font-size: 24px; /* Reduzido um pouco */
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        border-radius: 50%;
        cursor: pointer;
        z-index: 2147483640; /* Z-index alto mas permite outros elementos acima se necessário */
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
        display: flex !important; /* Usa flex para centralizar */
        align-items: center !important;
        justify-content: center !important;
        transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
        line-height: 1; /* Garante alinhamento vertical */
        user-select: none; /* Impede seleção de texto */
      }
      #${BUTTON_ID}:hover {
        transform: scale(1.1);
        box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
      }
      #${DROPDOWN_ID} {
        position: fixed;
        bottom: 80px; /* Ajustado para ficar acima do botão */
        right: 20px;
        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; /* Altura máxima */
        overflow-y: auto; /* Scroll se necessário */
        padding: 8px; /* Espaçamento interno */
        width: 300px; /* Largura do dropdown */
        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 de entrada */
      }
      .model-group {
        margin-bottom: 8px; /* Espaço entre grupos */
      }
      .group-header {
        padding: 8px 12px;
        font-weight: 600;
        color: #333;
        background: #f7f7f7;
        border-radius: 6px;
        margin-bottom: 4px;
        font-size: 13px; /* Tamanho ligeiramente menor */
        text-transform: uppercase; /* Caixa alta */
        letter-spacing: 0.5px; /* Espaçamento entre letras */
      }
      .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; /* Garante que ocupe toda a largura */
      }
      .model-item:hover {
        background-color: #eef6ff; /* Azul claro no hover */
        color: #1a73e8; /* Azul mais escuro no hover */
      }
      #${OVERLAY_ID} {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.6); /* Fundo semi-transparente mais escuro */
        z-index: 2147483645; /* Z-index muito alto */
        display: flex;
        align-items: center;
        justify-content: center;
        overflow: hidden; /* Impede scroll do body enquanto aberto */
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        animation: fadeIn 0.3s ease-out;
      }
      #${CONTENT_ID} {
        background-color: #fff;
        padding: 25px 35px; /* Ajuste no padding */
        border-radius: 12px; /* Bordas mais arredondadas */
        box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
        max-width: 800px; /* Largura máxima aumentada */
        width: 90%; /* Largura responsiva */
        max-height: 85vh; /* Altura máxima */
        overflow-y: auto; /* Scroll vertical se necessário */
        position: relative;
        font-size: 16px; /* Tamanho de fonte base */
        line-height: 1.6; /* Espaçamento entre linhas */
        color: #333;
        animation: slideInUp 0.3s ease-out; /* Animação de entrada */
      }
      #${CONTENT_ID} ul { padding-left: 25px; margin-top: 10px; } /* Estilo para listas */
      #${CONTENT_ID} li { margin-bottom: 8px; } /* Espaçamento entre itens da lista */
      #${CLOSE_BUTTON_ID} {
        position: absolute;
        top: 10px;
        right: 15px;
        font-size: 28px;
        color: #aaa;
        cursor: pointer;
        transition: color 0.2s;
        line-height: 1;
      }
      #${CLOSE_BUTTON_ID}:hover {
        color: #333; /* Cor mais escura no hover */
      }
      #${ERROR_ID} {
        position: fixed;
        bottom: 20px;
        left: 50%; /* Centralizado horizontalmente */
        transform: translateX(-50%); /* Ajuste fino da centralização */
        background-color: #e53e3e; /* Vermelho para erro */
        color: white;
        padding: 12px 20px;
        border-radius: 6px;
        z-index: 2147483646; /* Acima do overlay */
        font-size: 14px;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        box-shadow: 0 2px 8px rgba(0,0,0,0.2);
        animation: fadeIn 0.3s, fadeOut 0.3s 3.7s; /* Fade in e fade out */
      }
      .glow {
        font-size: 1.4em; /* Tamanho um pouco menor */
        color: #555;
        text-align: center;
        padding: 40px 0; /* Espaçamento vertical */
        animation: glow 1.8s ease-in-out infinite alternate;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        font-weight: 300; /* Fonte mais leve */
      }

      /* Animações */
      @keyframes glow {
        from { color: #4a90e2; text-shadow: 0 0 8px rgba(74, 144, 226, 0.5); }
        to { color: #7aa7d6; text-shadow: 0 0 15px rgba(122, 167, 214, 0.7); }
      }
      @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; }
      }
    `)
	}

	/**
	 * Exibe o overlay de sumarização com o conteúdo fornecido.
	 * Cria o overlay se ele não existir.
	 * @param {string} contentHTML - O conteúdo HTML a ser exibido (pode ser mensagem de loading ou o sumário).
	 */
	function showSummaryOverlay(contentHTML) {
		// Se o overlay já existe, apenas atualiza o conteúdo
		if (document.getElementById(OVERLAY_ID)) {
			updateSummaryOverlay(contentHTML)
			return
		}

		// Cria o elemento do overlay
		const overlay = document.createElement('div')
		overlay.id    = OVERLAY_ID
		overlay.innerHTML = `
      <div id="${CONTENT_ID}">
        <div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>
        ${contentHTML}
      </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)
		// Fecha clicando fora da caixa de conteúdo
		overlay.addEventListener('click', (e) => {
			// Verifica se o clique foi diretamente no overlay (fundo) e não dentro do content
			if (e.target === overlay) {
				closeOverlay()
			}
		})
		// Listener global de teclado para fechar com Esc já está em handleKeyPress
	}

	/**
	 * 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.
	 * @param {string} contentHTML - O novo conteúdo HTML.
	 */
	function updateSummaryOverlay(contentHTML) {
		const contentDiv = document.getElementById(CONTENT_ID)
		if (contentDiv) {
			// Recria o conteúdo interno, incluindo o botão de fechar
			contentDiv.innerHTML = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}`
			// Reatribui o listener ao novo botão de fechar
			document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
		}
	}

	/**
	 * Exibe uma notificação de erro temporária na parte inferior da tela.
	 * @param {string} message - A mensagem de erro.
	 */
	function showErrorNotification(message) {
		// Remove notificação anterior se existir
		document.getElementById(ERROR_ID)?.remove()

		// Cria a div de erro
		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.
	 * @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 (assumindo display 'block' ou 'flex' dependendo do elemento).
	 * @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'
		}
	}

	/**
	 * Configura listeners para esconder o botão 'S' quando um campo de input/textarea ganha foco,
	 * e mostrar novamente quando perde o foco.
	 */
	function setupFocusListeners() {
		// Listener para quando um elemento ganha foco
		document.addEventListener('focusin', (event) => {
			toggleButtonVisibility(event.target)
		})
		// Listener para quando um elemento perde foco (menos direto, usamos focusin)
		// 'focusout' pode ser complicado com elementos desaparecendo. 'focusin' é mais robusto aqui.
		// Adicionamos um listener de clique no documento para garantir que o botão reapareça
		// ao clicar fora de um input.
		document.addEventListener('click', (event) => {
			// Se o clique não foi no botão ou no dropdown, e o foco não está num input, mostra o botão
			if (!event.target.closest(`#${BUTTON_ID}`) && !event.target.closest(`#${DROPDOWN_ID}`)) {
				const active = document.activeElement
				const isInput = active?.matches('input, textarea, select, [contenteditable="true"]')
				if (!isInput) {
					// Apenas mostra se o artigo foi detectado
					if (articleData) {
						showElement(BUTTON_ID)
					}
				}
			}
		}, true) // Usa captura para pegar o evento antes
	}

	/**
	 * Mostra ou esconde o botão 'S' com base no elemento que tem o foco.
	 * @param {Element} focusedElement - O elemento que recebeu ou perdeu foco.
	 */
	function toggleButtonVisibility(focusedElement) {
		const button = document.getElementById(BUTTON_ID)
		if (!button || !articleData) return // Só age se o botão existir e o artigo for válido

		// Verifica se o elemento focado (ou um de seus pais) é um campo de entrada
		const isInput = focusedElement?.closest('input, textarea, select, [contenteditable="true"]')

		// Esconde o botão se for um input, mostra caso contrário
		if (isInput) {
			hideElement(BUTTON_ID)
			hideElement(DROPDOWN_ID) // Esconde também o dropdown por segurança
		} else {
			showElement(BUTTON_ID)
		}
	}

	// Inicia o script
	initialize()

})()