Summarize with AI (Unified)

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

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

  1. // ==UserScript==
  2. // @name Summarize with AI (Unified)
  3. // @namespace https://github.com/insign/userscripts
  4. // @version 2025.02.16.14.56
  5. // @description Single-button AI summarization (OpenAI/Gemini) with model selection dropdown for articles/news. Uses Alt+S shortcut.
  6. // @author Hélio <open@helio.me>
  7. // @license WTFPL
  8. // @match *://*/*
  9. // @grant GM.addStyle
  10. // @grant GM.xmlHttpRequest
  11. // @grant GM.setValue
  12. // @grant GM.getValue
  13. // @connect api.openai.com
  14. // @connect generativelanguage.googleapis.com
  15. // @require https://cdnjs.cloudflare.com/ajax/libs/readability/0.5.0/Readability.min.js
  16. // @require https://cdnjs.cloudflare.com/ajax/libs/readability/0.5.0/Readability-readerable.min.js
  17. // ==/UserScript==
  18.  
  19. (function() {
  20. 'use strict'
  21.  
  22. // IDs dos elementos da interface do script
  23. const BUTTON_ID = 'summarize-button' // Botão principal flutuante
  24. const DROPDOWN_ID = 'model-dropdown' // Dropdown de seleção de modelo
  25. const OVERLAY_ID = 'summarize-overlay' // Overlay de fundo para o sumário
  26. const CLOSE_BUTTON_ID = 'summarize-close' // Botão de fechar no overlay
  27. const CONTENT_ID = 'summarize-content' // Div que contém o texto do sumário
  28. const ERROR_ID = 'summarize-error' // Div para exibir notificações de erro
  29.  
  30. // Configuração dos serviços e modelos de IA suportados
  31. const MODEL_GROUPS = {
  32. openai: {
  33. name : 'OpenAI', // Nome do serviço
  34. models : ['gpt-4o-mini', 'o3-mini'], // Modelos disponíveis
  35. baseUrl: 'https://api.openai.com/v1/chat/completions', // URL base da API
  36. },
  37. gemini: {
  38. name : 'Gemini',
  39. models : [ // Modelos Gemini disponíveis
  40. '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',
  41. 'gemini-2.0-flash-lite-preview-02-05',
  42. ],
  43. baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models/', // URL base da API Gemini
  44. },
  45. }
  46.  
  47. // Template do prompt enviado para a IA
  48. // Inclui instruções sobre o formato desejado (HTML, introdução, bullets, conclusão) e o idioma.
  49. const PROMPT_TEMPLATE = (title, content, lang) => `You are a helpful assistant that provides clear and affirmative explanations of content.
  50. Generate a concise summary that includes:
  51. - 2-sentence introduction
  52. - Bullet points with relevant emojis
  53. - No section headers
  54. - Use HTML formatting, but send without \`\`\`html markdown syntax since it will be injected into the page to the browser evaluate correctly
  55. - After the last bullet point add a 2-sentence conclusion using opinionated language based on your general knowledge
  56. - Language: ${lang}
  57.  
  58. Article Title: ${title}
  59. Article Content: ${content}`
  60.  
  61. // Variáveis de estado
  62. let activeModel = 'gpt-4o-mini' // Modelo selecionado por padrão ou pelo usuário
  63. let articleData = null // Armazena o título e conteúdo extraído do artigo
  64.  
  65. /**
  66. * Função principal de inicialização do script.
  67. * Adiciona listener de teclado, tenta extrair dados do artigo,
  68. * e se bem-sucedido, adiciona o botão e listeners de foco.
  69. */
  70. function initialize() {
  71. document.addEventListener('keydown', handleKeyPress) // Listener para o atalho Alt+S
  72. articleData = getArticleData() // Tenta extrair o conteúdo do artigo
  73. if (articleData) { // Se encontrou conteúdo legível:
  74. addSummarizeButton() // Adiciona o botão flutuante e o dropdown
  75. showElement(BUTTON_ID) // Torna o botão visível
  76. setupFocusListeners() // Configura para esconder/mostrar botão em campos de input
  77. }
  78. }
  79.  
  80. /**
  81. * Tenta extrair o conteúdo principal da página usando a biblioteca Readability.js.
  82. * @returns {object|null} - Um objeto { title, content } se bem-sucedido, ou null se não for legível ou ocorrer erro.
  83. */
  84. function getArticleData() {
  85. try {
  86. // Clona o documento para não modificar o original
  87. const docClone = document.cloneNode(true)
  88. // Remove scripts e estilos do clone para evitar interferências com Readability
  89. docClone.querySelectorAll('script, style, noscript, iframe, figure, img').forEach(el => el.remove())
  90. // Verifica se a página é provavelmente legível antes de tentar parsear
  91. if (!isProbablyReaderable(docClone)) {
  92. console.log('Summarize with AI: Page not detected as readerable.')
  93. return null
  94. }
  95. const reader = new Readability(docClone)
  96. const article = reader.parse()
  97. // Retorna os dados se o conteúdo foi extraído com sucesso
  98. return article?.content ? { title: article.title, content: article.textContent.trim() } : null
  99. } catch (error) {
  100. console.error('Summarize with AI: Article parsing failed:', error)
  101. return null // Retorna null em caso de erro
  102. }
  103. }
  104.  
  105. /**
  106. * Adiciona o botão flutuante 'S' e o dropdown de seleção de modelo ao DOM.
  107. * Configura os event listeners do botão.
  108. */
  109. function addSummarizeButton() {
  110. // Evita adicionar o botão múltiplas vezes
  111. if (document.getElementById(BUTTON_ID)) return
  112.  
  113. // Cria o botão 'S'
  114. const button = document.createElement('div')
  115. button.id = BUTTON_ID
  116. button.textContent = 'S' // Texto simples, mantido pequeno
  117. document.body.appendChild(button)
  118.  
  119. // Cria o dropdown (inicialmente oculto)
  120. const dropdown = createDropdown()
  121. document.body.appendChild(dropdown)
  122.  
  123. // Listener para clique simples: mostra/esconde o dropdown
  124. button.addEventListener('click', toggleDropdown)
  125. // Listener para duplo clique: permite resetar a chave da API
  126. button.addEventListener('dblclick', handleApiKeyReset)
  127.  
  128. // Injeta os estilos CSS necessários para a interface
  129. injectStyles()
  130. }
  131.  
  132. /**
  133. * Cria o elemento do dropdown com os grupos de modelos.
  134. * @returns {HTMLElement} - O elemento div do dropdown.
  135. */
  136. function createDropdown() {
  137. const dropdown = document.createElement('div')
  138. dropdown.id = DROPDOWN_ID
  139. dropdown.style.display = 'none' // Começa oculto
  140.  
  141. // Itera sobre os grupos de modelos (OpenAI, Gemini)
  142. Object.entries(MODEL_GROUPS).forEach(([service, group]) => {
  143. const groupDiv = document.createElement('div')
  144. groupDiv.className = 'model-group'
  145. // Adiciona o cabeçalho do grupo (e.g., "OpenAI")
  146. groupDiv.appendChild(createHeader(group.name))
  147. // Adiciona cada item de modelo dentro do grupo
  148. group.models.forEach(model => groupDiv.appendChild(createModelItem(model)))
  149. dropdown.appendChild(groupDiv) // Adiciona o grupo ao dropdown
  150. })
  151. return dropdown
  152. }
  153.  
  154. /**
  155. * Cria um elemento de cabeçalho para um grupo de modelos no dropdown.
  156. * @param {string} text - O texto do cabeçalho (nome do serviço).
  157. * @returns {HTMLElement} - O elemento div do cabeçalho.
  158. */
  159. function createHeader(text) {
  160. const header = document.createElement('div')
  161. header.className = 'group-header'
  162. header.textContent = text
  163. return header
  164. }
  165.  
  166. /**
  167. * Cria um item clicável para um modelo específico no dropdown.
  168. * @param {string} model - O nome do modelo.
  169. * @returns {HTMLElement} - O elemento div do item do modelo.
  170. */
  171. function createModelItem(model) {
  172. const item = document.createElement('div')
  173. item.className = 'model-item'
  174. item.textContent = model
  175. // Listener de clique: seleciona o modelo, esconde dropdown e inicia sumarização
  176. item.addEventListener('click', () => {
  177. activeModel = model // Define o modelo ativo
  178. hideElement(DROPDOWN_ID) // Esconde o dropdown
  179. processSummarization() // Inicia o processo de sumarização
  180. })
  181. return item
  182. }
  183.  
  184. /**
  185. * Orquestra o processo de sumarização: obtém API key, mostra overlay de loading,
  186. * envia requisição à API e trata a resposta.
  187. */
  188. async function processSummarization() {
  189. try {
  190. const service = getCurrentService() // Determina qual serviço (openai/gemini) usar com base no `activeModel`
  191. const apiKey = await getApiKey(service) // Obtém a API key (pede ao usuário se não tiver)
  192.  
  193. // Aborta se não houver API key
  194. if (!apiKey) {
  195. showErrorNotification(`API key for ${service.toUpperCase()} is required. Double-click the 'S' button to set it.`)
  196. return
  197. }
  198.  
  199. // Mostra o overlay com mensagem de "Summarizing..."
  200. showSummaryOverlay('<p class="glow">Summarizing...</p>')
  201.  
  202. // Prepara os dados para a API
  203. const payload = { title: articleData.title, content: articleData.content, lang: navigator.language || 'en-US' }
  204.  
  205. // Envia a requisição para a API apropriada
  206. const response = await sendApiRequest(service, apiKey, payload)
  207.  
  208. // Processa a resposta da API
  209. handleApiResponse(response, service)
  210.  
  211. } catch (error) {
  212. // Exibe erros no overlay ou como notificação
  213. const errorMsg = `Error: ${error.message}`
  214. console.error('Summarize with AI:', errorMsg)
  215. if (document.getElementById(OVERLAY_ID)) {
  216. updateSummaryOverlay(`<p style="color: red;">${errorMsg}</p>`)
  217. } else {
  218. showErrorNotification(errorMsg)
  219. }
  220. // Garante que o dropdown esteja oculto em caso de erro durante o processamento
  221. hideElement(DROPDOWN_ID)
  222. }
  223. }
  224.  
  225. /**
  226. * Envia a requisição HTTP para a API de IA (OpenAI ou Gemini).
  227. * Usa GM.xmlHttpRequest para contornar restrições de CORS.
  228. * @param {string} service - 'openai' ou 'gemini'.
  229. * @param {string} apiKey - A chave da API para o serviço.
  230. * @param {object} payload - Objeto com { title, content, lang }.
  231. * @returns {Promise<object>} - A promessa resolve com o objeto de resposta da requisição.
  232. */
  233. async function sendApiRequest(service, apiKey, payload) {
  234. // Monta a URL da API baseada no serviço e modelo ativo
  235. const url = service === 'openai'
  236. ? MODEL_GROUPS.openai.baseUrl
  237. : `${MODEL_GROUPS.gemini.baseUrl}${activeModel}:generateContent?key=${apiKey}` // Gemini inclui a key na URL
  238.  
  239. return new Promise((resolve, reject) => {
  240. GM.xmlHttpRequest({
  241. method : 'POST',
  242. url : url,
  243. headers: getHeaders(service, apiKey), // Obtém os cabeçalhos corretos para o serviço
  244. data : JSON.stringify(buildRequestBody(service, payload)), // Constrói o corpo da requisição
  245. onload : response => resolve(response), // Resolve a promessa com a resposta em caso de sucesso
  246. onerror: error => reject(new Error(`Network error: ${error.statusText || 'Failed to connect'}`)), // Rejeita em caso de erro de rede
  247. onabort: () => reject(new Error('Request aborted')), // Rejeita se a requisição for abortada
  248. ontimeout: () => reject(new Error('Request timed out')), // Rejeita em caso de timeout
  249. })
  250. })
  251. }
  252.  
  253. /**
  254. * Processa a resposta da API, extrai o sumário e atualiza o overlay.
  255. * @param {object} response - O objeto de resposta da requisição (de GM.xmlHttpRequest).
  256. * @param {string} service - 'openai' ou 'gemini'.
  257. */
  258. function handleApiResponse(response, service) {
  259. // Verifica se o status da resposta HTTP é 200 (OK)
  260. if (response.status !== 200) {
  261. let errorDetails = response.statusText
  262. try {
  263. // Tenta extrair uma mensagem de erro mais detalhada do corpo da resposta JSON
  264. const errorData = JSON.parse(response.responseText)
  265. errorDetails = errorData?.error?.message || errorDetails
  266. } catch (e) { /* Ignora se não conseguir parsear o JSON do erro */ }
  267. throw new Error(`API Error (${response.status}): ${errorDetails}`)
  268. }
  269.  
  270. // Parseia o corpo da resposta JSON
  271. const data = JSON.parse(response.responseText)
  272.  
  273. // Extrai o conteúdo do sumário dependendo do serviço
  274. let summary = ''
  275. if (service === 'openai') {
  276. summary = data?.choices?.[0]?.message?.content
  277. } else if (service === 'gemini') {
  278. summary = data?.candidates?.[0]?.content?.parts?.[0]?.text
  279. }
  280.  
  281. // Verifica se o sumário foi obtido
  282. if (!summary) {
  283. throw new Error('API response did not contain a valid summary.')
  284. }
  285.  
  286. // Atualiza o overlay com o sumário formatado (substitui novas linhas por <br>)
  287. updateSummaryOverlay(summary.replace(/\n/g, '<br>'))
  288. }
  289.  
  290. /**
  291. * Constrói o corpo (payload) da requisição para a API, formatado corretamente
  292. * para OpenAI ou Gemini.
  293. * @param {string} service - 'openai' ou 'gemini'.
  294. * @param {object} payload - Objeto com { title, content, lang }.
  295. * @returns {object} - O objeto do corpo da requisição.
  296. */
  297. function buildRequestBody(service, { title, content, lang }) {
  298. const systemPrompt = PROMPT_TEMPLATE(title, content, lang) // Gera o prompt do sistema
  299.  
  300. if (service === 'openai') {
  301. return {
  302. model : activeModel,
  303. messages : [
  304. { role: 'system', content: systemPrompt },
  305. { role: 'user', content: 'Generate the summary as requested.' }, // Mensagem curta do usuário
  306. ],
  307. temperature: 0.5, // Controla a criatividade/determinismo
  308. max_tokens : 500, // Limita o tamanho da resposta
  309. }
  310. } else { // gemini
  311. return {
  312. contents: [
  313. {
  314. parts: [
  315. { text: systemPrompt }, // Gemini usa uma estrutura diferente
  316. ],
  317. },
  318. ],
  319. // Configurações de geração podem ser adicionadas aqui se necessário (e suportado pelo modelo)
  320. // "generationConfig": { "temperature": 0.5, "maxOutputTokens": 500 }
  321. }
  322. }
  323. }
  324.  
  325. /**
  326. * Retorna os cabeçalhos HTTP apropriados para a requisição da API.
  327. * OpenAI requer 'Authorization: Bearer <key>'. Gemini não (key na URL).
  328. * @param {string} service - 'openai' ou 'gemini'.
  329. * @param {string} apiKey - A chave da API.
  330. * @returns {object} - O objeto de cabeçalhos.
  331. */
  332. function getHeaders(service, apiKey) {
  333. const headers = { 'Content-Type': 'application/json' }
  334. if (service === 'openai') {
  335. headers['Authorization'] = `Bearer ${apiKey}`
  336. }
  337. return headers
  338. }
  339.  
  340. /**
  341. * Determina qual serviço ('openai' ou 'gemini') corresponde ao `activeModel` atual.
  342. * @returns {string|undefined} - O nome do serviço ou undefined se não encontrado.
  343. */
  344. function getCurrentService() {
  345. // Encontra a chave (nome do serviço) cujo array `models` inclui o `activeModel`
  346. return Object.keys(MODEL_GROUPS).find(service => MODEL_GROUPS[service].models.includes(activeModel))
  347. }
  348.  
  349. /**
  350. * Mostra ou esconde o dropdown de seleção de modelo.
  351. * @param {Event} e - O objeto do evento de clique.
  352. */
  353. function toggleDropdown(e) {
  354. e.stopPropagation() // Impede que o clique se propague para o document (que fecharia o dropdown)
  355. const dropdown = document.getElementById(DROPDOWN_ID)
  356. if (dropdown) {
  357. dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'
  358. }
  359. }
  360.  
  361. /**
  362. * Manipulador para o atalho de teclado (Alt+S).
  363. * Simula um clique no botão 'S' se ele existir.
  364. * @param {KeyboardEvent} e - O objeto do evento de teclado.
  365. */
  366. function handleKeyPress(e) {
  367. if (e.altKey && e.code === 'KeyS') { // Verifica se Alt+S foi pressionado
  368. e.preventDefault() // Previne a ação padrão do navegador (ex: abrir menu de histórico)
  369. const button = document.getElementById(BUTTON_ID)
  370. if (button) {
  371. // Se o dropdown estiver visível, esconde; senão, mostra (simula clique)
  372. const dropdown = document.getElementById(DROPDOWN_ID)
  373. if (dropdown && dropdown.style.display === 'block') {
  374. hideElement(DROPDOWN_ID)
  375. } else {
  376. button.click()
  377. }
  378. }
  379. }
  380. // Fecha o overlay ou dropdown com a tecla Esc
  381. if (e.key === 'Escape') {
  382. closeOverlay()
  383. hideElement(DROPDOWN_ID)
  384. }
  385. }
  386.  
  387. /**
  388. * Obtém a chave da API para o serviço especificado a partir do armazenamento (GM.getValue).
  389. * Se não existir, pede ao usuário via prompt e armazena (GM.setValue).
  390. * @param {string} service - 'openai' ou 'gemini'.
  391. * @returns {Promise<string|null>} - A chave da API ou null se não for fornecida.
  392. */
  393. async function getApiKey(service) {
  394. const storageKey = `${service}_api_key` // Chave usada para armazenar a API key
  395. let apiKey = await GM.getValue(storageKey) // Tenta ler do armazenamento
  396.  
  397. if (!apiKey) {
  398. // Pede ao usuário se não encontrou a chave
  399. apiKey = prompt(`Enter your ${service.toUpperCase()} API key:`)
  400. if (apiKey) {
  401. apiKey = apiKey.trim() // Remove espaços extras
  402. await GM.setValue(storageKey, apiKey) // Salva a chave fornecida
  403. } else {
  404. return null // Retorna null se o usuário cancelar ou não inserir nada
  405. }
  406. }
  407. return apiKey?.trim() // Retorna a chave (do armazenamento ou recém-inserida)
  408. }
  409.  
  410. /**
  411. * Permite ao usuário resetar (redefinir) a chave da API para um serviço.
  412. * Ativado por duplo clique no botão 'S'.
  413. */
  414. async function handleApiKeyReset() {
  415. // Pergunta para qual serviço resetar
  416. const service = prompt('Reset API key for which service? (openai / gemini)')?.toLowerCase()?.trim()
  417.  
  418. if (service && MODEL_GROUPS[service]) { // Verifica se o serviço é válido
  419. // Pede a nova chave
  420. const newKey = prompt(`Enter the new ${service.toUpperCase()} API key (leave blank to clear):`)
  421. if (newKey !== null) { // Verifica se o usuário não cancelou
  422. await GM.setValue(`${service}_api_key`, newKey.trim()) // Salva a nova chave (ou string vazia para limpar)
  423. alert(`${service.toUpperCase()} API key updated!`)
  424. }
  425. } else if (service) {
  426. alert('Invalid service name. Please enter "openai" or "gemini".')
  427. }
  428. }
  429.  
  430. /**
  431. * Injeta os estilos CSS necessários para a interface do script na página.
  432. * Usa GM.addStyle para adicionar os estilos.
  433. */
  434. function injectStyles() {
  435. GM.addStyle(`
  436. #${BUTTON_ID} {
  437. position: fixed;
  438. bottom: 20px;
  439. right: 20px;
  440. width: 50px; /* Reduzido um pouco */
  441. height: 50px; /* Reduzido um pouco */
  442. background: linear-gradient(145deg, #3a7bd5, #00d2ff); /* Gradiente azul */
  443. color: white;
  444. font-size: 24px; /* Reduzido um pouco */
  445. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  446. border-radius: 50%;
  447. cursor: pointer;
  448. z-index: 2147483640; /* Z-index alto mas permite outros elementos acima se necessário */
  449. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  450. display: flex !important; /* Usa flex para centralizar */
  451. align-items: center !important;
  452. justify-content: center !important;
  453. transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
  454. line-height: 1; /* Garante alinhamento vertical */
  455. user-select: none; /* Impede seleção de texto */
  456. }
  457. #${BUTTON_ID}:hover {
  458. transform: scale(1.1);
  459. box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
  460. }
  461. #${DROPDOWN_ID} {
  462. position: fixed;
  463. bottom: 80px; /* Ajustado para ficar acima do botão */
  464. right: 20px;
  465. background: #ffffff;
  466. border: 1px solid #e0e0e0;
  467. border-radius: 10px;
  468. box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
  469. z-index: 2147483641; /* Acima do botão */
  470. max-height: 70vh; /* Altura máxima */
  471. overflow-y: auto; /* Scroll se necessário */
  472. padding: 8px; /* Espaçamento interno */
  473. width: 300px; /* Largura do dropdown */
  474. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  475. display: none; /* Começa oculto */
  476. animation: fadeIn 0.2s ease-out; /* Animação de entrada */
  477. }
  478. .model-group {
  479. margin-bottom: 8px; /* Espaço entre grupos */
  480. }
  481. .group-header {
  482. padding: 8px 12px;
  483. font-weight: 600;
  484. color: #333;
  485. background: #f7f7f7;
  486. border-radius: 6px;
  487. margin-bottom: 4px;
  488. font-size: 13px; /* Tamanho ligeiramente menor */
  489. text-transform: uppercase; /* Caixa alta */
  490. letter-spacing: 0.5px; /* Espaçamento entre letras */
  491. }
  492. .model-item {
  493. padding: 10px 14px;
  494. margin: 2px 0;
  495. border-radius: 6px;
  496. transition: background-color 0.15s ease-out, color 0.15s ease-out;
  497. font-size: 14px;
  498. cursor: pointer;
  499. color: #444;
  500. display: block; /* Garante que ocupe toda a largura */
  501. }
  502. .model-item:hover {
  503. background-color: #eef6ff; /* Azul claro no hover */
  504. color: #1a73e8; /* Azul mais escuro no hover */
  505. }
  506. #${OVERLAY_ID} {
  507. position: fixed;
  508. top: 0;
  509. left: 0;
  510. width: 100%;
  511. height: 100%;
  512. background-color: rgba(0, 0, 0, 0.6); /* Fundo semi-transparente mais escuro */
  513. z-index: 2147483645; /* Z-index muito alto */
  514. display: flex;
  515. align-items: center;
  516. justify-content: center;
  517. overflow: hidden; /* Impede scroll do body enquanto aberto */
  518. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  519. animation: fadeIn 0.3s ease-out;
  520. }
  521. #${CONTENT_ID} {
  522. background-color: #fff;
  523. padding: 25px 35px; /* Ajuste no padding */
  524. border-radius: 12px; /* Bordas mais arredondadas */
  525. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  526. max-width: 800px; /* Largura máxima aumentada */
  527. width: 90%; /* Largura responsiva */
  528. max-height: 85vh; /* Altura máxima */
  529. overflow-y: auto; /* Scroll vertical se necessário */
  530. position: relative;
  531. font-size: 16px; /* Tamanho de fonte base */
  532. line-height: 1.6; /* Espaçamento entre linhas */
  533. color: #333;
  534. animation: slideInUp 0.3s ease-out; /* Animação de entrada */
  535. }
  536. #${CONTENT_ID} ul { padding-left: 25px; margin-top: 10px; } /* Estilo para listas */
  537. #${CONTENT_ID} li { margin-bottom: 8px; } /* Espaçamento entre itens da lista */
  538. #${CLOSE_BUTTON_ID} {
  539. position: absolute;
  540. top: 10px;
  541. right: 15px;
  542. font-size: 28px;
  543. color: #aaa;
  544. cursor: pointer;
  545. transition: color 0.2s;
  546. line-height: 1;
  547. }
  548. #${CLOSE_BUTTON_ID}:hover {
  549. color: #333; /* Cor mais escura no hover */
  550. }
  551. #${ERROR_ID} {
  552. position: fixed;
  553. bottom: 20px;
  554. left: 50%; /* Centralizado horizontalmente */
  555. transform: translateX(-50%); /* Ajuste fino da centralização */
  556. background-color: #e53e3e; /* Vermelho para erro */
  557. color: white;
  558. padding: 12px 20px;
  559. border-radius: 6px;
  560. z-index: 2147483646; /* Acima do overlay */
  561. font-size: 14px;
  562. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  563. box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  564. animation: fadeIn 0.3s, fadeOut 0.3s 3.7s; /* Fade in e fade out */
  565. }
  566. .glow {
  567. font-size: 1.4em; /* Tamanho um pouco menor */
  568. color: #555;
  569. text-align: center;
  570. padding: 40px 0; /* Espaçamento vertical */
  571. animation: glow 1.8s ease-in-out infinite alternate;
  572. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  573. font-weight: 300; /* Fonte mais leve */
  574. }
  575.  
  576. /* Animações */
  577. @keyframes glow {
  578. from { color: #4a90e2; text-shadow: 0 0 8px rgba(74, 144, 226, 0.5); }
  579. to { color: #7aa7d6; text-shadow: 0 0 15px rgba(122, 167, 214, 0.7); }
  580. }
  581. @keyframes fadeIn {
  582. from { opacity: 0; }
  583. to { opacity: 1; }
  584. }
  585. @keyframes fadeOut {
  586. from { opacity: 1; }
  587. to { opacity: 0; }
  588. }
  589. @keyframes slideInUp {
  590. from { transform: translateY(30px); opacity: 0; }
  591. to { transform: translateY(0); opacity: 1; }
  592. }
  593. `)
  594. }
  595.  
  596. /**
  597. * Exibe o overlay de sumarização com o conteúdo fornecido.
  598. * Cria o overlay se ele não existir.
  599. * @param {string} contentHTML - O conteúdo HTML a ser exibido (pode ser mensagem de loading ou o sumário).
  600. */
  601. function showSummaryOverlay(contentHTML) {
  602. // Se o overlay já existe, apenas atualiza o conteúdo
  603. if (document.getElementById(OVERLAY_ID)) {
  604. updateSummaryOverlay(contentHTML)
  605. return
  606. }
  607.  
  608. // Cria o elemento do overlay
  609. const overlay = document.createElement('div')
  610. overlay.id = OVERLAY_ID
  611. overlay.innerHTML = `
  612. <div id="${CONTENT_ID}">
  613. <div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>
  614. ${contentHTML}
  615. </div>
  616. `
  617. document.body.appendChild(overlay)
  618. document.body.style.overflow = 'hidden' // Trava o scroll do body
  619.  
  620. // Adiciona listeners para fechar o overlay
  621. document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
  622. // Fecha clicando fora da caixa de conteúdo
  623. overlay.addEventListener('click', (e) => {
  624. // Verifica se o clique foi diretamente no overlay (fundo) e não dentro do content
  625. if (e.target === overlay) {
  626. closeOverlay()
  627. }
  628. })
  629. // Listener global de teclado para fechar com Esc já está em handleKeyPress
  630. }
  631.  
  632. /**
  633. * Fecha e remove o overlay de sumarização do DOM.
  634. * Restaura o scroll do body.
  635. */
  636. function closeOverlay() {
  637. const overlay = document.getElementById(OVERLAY_ID)
  638. if (overlay) {
  639. overlay.remove()
  640. document.body.style.overflow = '' // Libera o scroll do body
  641. }
  642. }
  643.  
  644.  
  645. /**
  646. * Atualiza o conteúdo dentro do overlay de sumarização já existente.
  647. * @param {string} contentHTML - O novo conteúdo HTML.
  648. */
  649. function updateSummaryOverlay(contentHTML) {
  650. const contentDiv = document.getElementById(CONTENT_ID)
  651. if (contentDiv) {
  652. // Recria o conteúdo interno, incluindo o botão de fechar
  653. contentDiv.innerHTML = `<div id="${CLOSE_BUTTON_ID}" title="Close (Esc)">×</div>${contentHTML}`
  654. // Reatribui o listener ao novo botão de fechar
  655. document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
  656. }
  657. }
  658.  
  659. /**
  660. * Exibe uma notificação de erro temporária na parte inferior da tela.
  661. * @param {string} message - A mensagem de erro.
  662. */
  663. function showErrorNotification(message) {
  664. // Remove notificação anterior se existir
  665. document.getElementById(ERROR_ID)?.remove()
  666.  
  667. // Cria a div de erro
  668. const errorDiv = document.createElement('div')
  669. errorDiv.id = ERROR_ID
  670. errorDiv.innerText = message
  671. document.body.appendChild(errorDiv)
  672.  
  673. // Remove a notificação após 4 segundos
  674. setTimeout(() => errorDiv.remove(), 4000)
  675. }
  676.  
  677. /**
  678. * Esconde um elemento pelo seu ID.
  679. * @param {string} id - O ID do elemento.
  680. */
  681. function hideElement(id) {
  682. const el = document.getElementById(id)
  683. if (el) el.style.display = 'none'
  684. }
  685.  
  686. /**
  687. * Mostra um elemento pelo seu ID (assumindo display 'block' ou 'flex' dependendo do elemento).
  688. * @param {string} id - O ID do elemento.
  689. */
  690. function showElement(id) {
  691. const el = document.getElementById(id)
  692. if (el) {
  693. // Usa 'flex' para o botão e 'block' para os outros por padrão
  694. el.style.display = (id === BUTTON_ID) ? 'flex' : 'block'
  695. }
  696. }
  697.  
  698. /**
  699. * Configura listeners para esconder o botão 'S' quando um campo de input/textarea ganha foco,
  700. * e mostrar novamente quando perde o foco.
  701. */
  702. function setupFocusListeners() {
  703. // Listener para quando um elemento ganha foco
  704. document.addEventListener('focusin', (event) => {
  705. toggleButtonVisibility(event.target)
  706. })
  707. // Listener para quando um elemento perde foco (menos direto, usamos focusin)
  708. // 'focusout' pode ser complicado com elementos desaparecendo. 'focusin' é mais robusto aqui.
  709. // Adicionamos um listener de clique no documento para garantir que o botão reapareça
  710. // ao clicar fora de um input.
  711. document.addEventListener('click', (event) => {
  712. // Se o clique não foi no botão ou no dropdown, e o foco não está num input, mostra o botão
  713. if (!event.target.closest(`#${BUTTON_ID}`) && !event.target.closest(`#${DROPDOWN_ID}`)) {
  714. const active = document.activeElement
  715. const isInput = active?.matches('input, textarea, select, [contenteditable="true"]')
  716. if (!isInput) {
  717. // Apenas mostra se o artigo foi detectado
  718. if (articleData) {
  719. showElement(BUTTON_ID)
  720. }
  721. }
  722. }
  723. }, true) // Usa captura para pegar o evento antes
  724. }
  725.  
  726. /**
  727. * Mostra ou esconde o botão 'S' com base no elemento que tem o foco.
  728. * @param {Element} focusedElement - O elemento que recebeu ou perdeu foco.
  729. */
  730. function toggleButtonVisibility(focusedElement) {
  731. const button = document.getElementById(BUTTON_ID)
  732. if (!button || !articleData) return // Só age se o botão existir e o artigo for válido
  733.  
  734. // Verifica se o elemento focado (ou um de seus pais) é um campo de entrada
  735. const isInput = focusedElement?.closest('input, textarea, select, [contenteditable="true"]')
  736.  
  737. // Esconde o botão se for um input, mostra caso contrário
  738. if (isInput) {
  739. hideElement(BUTTON_ID)
  740. hideElement(DROPDOWN_ID) // Esconde também o dropdown por segurança
  741. } else {
  742. showElement(BUTTON_ID)
  743. }
  744. }
  745.  
  746. // Inicia o script
  747. initialize()
  748.  
  749. })()