// ==UserScript==
// @name Summarize with AI (Unified)
// @namespace https://github.com/insign/summarize-with-ai
// @version 2025.02.16.14.56
// @description Single-button AI summarization with model selection dropdown
// @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'
const BUTTON_ID = 'summarize-button'
const DROPDOWN_ID = 'model-dropdown'
const OVERLAY_ID = 'summarize-overlay'
const CLOSE_BUTTON_ID = 'summarize-close'
const CONTENT_ID = 'summarize-content'
const ERROR_ID = 'summarize-error'
const MODEL_GROUPS = {
openai : {
name: 'OpenAI', models: [ 'gpt-4o-mini', 'o3-mini' ], baseUrl: 'https://api.openai.com/v1/chat/completions',
}, gemini: {
name : 'Gemini', models: [
'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/',
},
}
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 withouy \`\`\`html markdown syntax since it well be injected into the page to the browser evaluate correctly
- After the last bullet point add a 2-sentence conclusion using opinionated language your general knowledge
- Language: ${lang}
Article Title: ${title}
Article Content: ${content}`
let activeModel = 'gpt-4o-mini'
let articleData = null
function initialize() {
document.addEventListener('keydown', handleKeyPress)
articleData = getArticleData()
if (articleData) {
addSummarizeButton()
showElement(BUTTON_ID)
setupFocusListeners() // Movido para dentro do bloco condicional
}
}
function getArticleData() {
try {
const docClone = document.cloneNode(true)
docClone.querySelectorAll('script, style').forEach(el => el.remove())
if (!isProbablyReaderable(docClone)) return null
const reader = new Readability(docClone)
const article = reader.parse()
return article?.content ? { title: article.title, content: article.textContent } : null
}
catch (error) {
console.error('Article parsing failed:', error)
return null
}
}
function addSummarizeButton() {
if (document.getElementById(BUTTON_ID)) return
const button = document.createElement('div')
button.id = BUTTON_ID
button.textContent = 'S'
document.body.appendChild(button)
const dropdown = createDropdown()
document.body.appendChild(dropdown)
button.addEventListener('click', toggleDropdown)
button.addEventListener('dblclick', handleApiKeyReset)
injectStyles()
}
function createDropdown() {
const dropdown = document.createElement('div')
dropdown.id = DROPDOWN_ID
dropdown.style.display = 'none'
Object.entries(MODEL_GROUPS).forEach(([ service, group ]) => {
const groupDiv = document.createElement('div')
groupDiv.className = 'model-group'
groupDiv.appendChild(createHeader(group.name))
group.models.forEach(model => groupDiv.appendChild(createModelItem(model)))
dropdown.appendChild(groupDiv)
})
return dropdown
}
function createHeader(text) {
const header = document.createElement('div')
header.className = 'group-header'
header.textContent = text
return header
}
function createModelItem(model) {
const item = document.createElement('div')
item.className = 'model-item'
item.textContent = model
item.addEventListener('click', () => {
activeModel = model
hideElement(DROPDOWN_ID)
processSummarization()
})
return item
}
async function processSummarization() {
try {
const service = getCurrentService()
const apiKey = await getApiKey(service)
if (!apiKey) return
showSummaryOverlay('<p class="glow">Summarizing...</p>')
const payload = { title: articleData.title, content: articleData.content, lang: navigator.language || 'en-US' }
const response = await sendApiRequest(service, apiKey, payload)
handleApiResponse(response, service)
}
catch (error) {
showErrorNotification(`Error: ${error.message}`)
}
}
async function sendApiRequest(service, apiKey, payload) {
const url = service === 'openai' ? MODEL_GROUPS.openai.baseUrl : `${MODEL_GROUPS.gemini.baseUrl}${activeModel}:generateContent?key=${apiKey}`
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method : 'POST',
url,
headers: getHeaders(service, apiKey),
data : JSON.stringify(buildRequestBody(service, payload)),
onload : resolve,
onerror: reject,
onabort: () => reject(new Error('Request aborted')),
})
})
}
function handleApiResponse(response, service) {
if (response.status !== 200) {
throw new Error(`API Error (${response.status}): ${response.statusText}`)
}
const data = JSON.parse(response.responseText)
const summary = service === 'openai' ? data.choices[0].message.content : data.candidates[0].content.parts[0].text
updateSummaryOverlay(summary.replace(/\n/g, '<br>'))
}
function buildRequestBody(service, { title, content, lang }) {
return service === 'openai' ? {
model : activeModel, messages: [
{
role: 'system', content: PROMPT_TEMPLATE(title, content, lang),
}, {
role: 'user', content: 'Generate summary',
},
], temperature: 0.5, max_tokens: 500,
} : {
contents: [
{
parts: [
{
text: PROMPT_TEMPLATE(title, content, lang),
},
],
},
],
}
}
function getHeaders(service, apiKey) {
return service === 'openai' ? {
'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`,
} : { 'Content-Type': 'application/json' }
}
function getCurrentService() {
return Object.keys(MODEL_GROUPS).find(service => MODEL_GROUPS[service].models.includes(activeModel))
}
function toggleDropdown(e) {
e.stopPropagation()
const dropdown = document.getElementById(DROPDOWN_ID)
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'
}
function handleKeyPress(e) {
if (e.altKey && e.code === 'KeyS') {
e.preventDefault()
document.getElementById(BUTTON_ID)?.click()
}
}
async function getApiKey(service) {
const storageKey = `${service}_api_key`
let apiKey = await GM.getValue(storageKey)
if (!apiKey) {
apiKey = prompt(`Enter ${service.toUpperCase()} API key:`)
if (apiKey) await GM.setValue(storageKey, apiKey.trim())
}
return apiKey?.trim()
}
function handleApiKeyReset() {
const service = prompt('Reset API key for (openai/gemini):').toLowerCase()
if (MODEL_GROUPS[service]) {
const newKey = prompt(`Enter new ${service} API key:`)
if (newKey) GM.setValue(`${service}_api_key`, newKey.trim())
}
}
function injectStyles() {
GM.addStyle(`
#${BUTTON_ID} {
position: fixed;
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
background: #2563eb;
color: white;
font-size: 28px;
font-family: sans-serif;
border-radius: 50%;
cursor: pointer;
z-index: 99999;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: transform 0.2s;
line-height: 1;
}
#${DROPDOWN_ID} {
position: fixed;
bottom: 90px;
right: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 100000;
max-height: 60vh;
overflow-y: auto;
padding: 12px;
width: 280px;
font-family: sans-serif;
}
.model-group { margin: 8px 0; }
.group-header {
padding: 8px 12px;
font-weight: 600;
color: #4b5563;
background: #f3f4f6;
border-radius: 4px;
margin-bottom: 6px;
font-family: sans-serif;
}
.model-item {
padding: 10px 16px;
margin: 4px 0;
border-radius: 6px;
transition: background 0.2s;
font-size: 14px;
font-family: sans-serif;
cursor: pointer;
}
.model-item:hover { background: #1143b2; }
#${OVERLAY_ID} {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
font-family: sans-serif;
}
#${CONTENT_ID} {
background-color: #fff;
padding: 30px;
border-radius: 10px;
box-shadow: 0 0 15px rgba(0,0,0,0.5);
max-width: 700px;
max-height: 90%;
overflow: auto;
position: relative;
font-size: 1.2em;
color: #000;
font-family: sans-serif;
}
#${ERROR_ID} {
position: fixed;
bottom: 20px;
left: 20px;
background-color: rgba(255,0,0,0.8);
color: white;
padding: 10px 20px;
border-radius: 5px;
z-index: 100001;
font-size: 14px;
font-family: sans-serif;
}
.glow {
font-size: 1.5em;
color: #333;
text-align: center;
animation: glow 2s ease-in-out infinite alternate;
font-family: sans-serif;
}
@keyframes glow {
from { color: #4b6cb7; text-shadow: 0 0 10px #4b6cb7; }
to { color: #182848; text-shadow: 0 0 20px #8e2de2; }
}
`)
}
function showSummaryOverlay(content) {
if (document.getElementById(OVERLAY_ID)) {
updateSummaryOverlay(content)
return
}
const overlay = document.createElement('div')
overlay.id = OVERLAY_ID
overlay.innerHTML = `
<div id="${CONTENT_ID}">
<div id="${CLOSE_BUTTON_ID}">×</div>
${content}
</div>
`
document.body.appendChild(overlay)
document.body.style.overflow = 'hidden'
document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
overlay.addEventListener('click', (e) => {
if (!e.target.closest(`#${CONTENT_ID}`)) closeOverlay()
})
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeOverlay()
})
function closeOverlay() {
document.getElementById(OVERLAY_ID)?.remove()
document.body.style.overflow = ''
}
}
function updateSummaryOverlay(content) {
const contentDiv = document.getElementById(CONTENT_ID)
if (contentDiv) {
contentDiv.innerHTML = `<div id="${CLOSE_BUTTON_ID}">×</div>${content}`
document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
}
function closeOverlay() {
document.getElementById(OVERLAY_ID)?.remove()
document.body.style.overflow = ''
}
}
function showErrorNotification(message) {
if (document.getElementById(ERROR_ID)) {
document.getElementById(ERROR_ID).innerText = message
return
}
const errorDiv = document.createElement('div')
errorDiv.id = ERROR_ID
errorDiv.innerText = message
document.body.appendChild(errorDiv)
setTimeout(() => errorDiv.remove(), 4000)
}
function hideElement(id) {
const el = document.getElementById(id)
if (el) el.style.display = 'none'
}
function showElement(id) {
const el = document.getElementById(id)
if (el) el.style.display = 'block'
}
function setupFocusListeners() {
document.addEventListener('focusin', toggleButtonVisibility)
document.addEventListener('focusout', toggleButtonVisibility)
}
function toggleButtonVisibility() {
const button = document.getElementById(BUTTON_ID)
if (!button) return // Previne erro em páginas sem botão
const active = document.activeElement
const isInput = active?.matches('input, textarea, select, [contenteditable]')
button.style.display = isInput ? 'none' : 'block'
}
initialize()
})()