Summarize with AI (Unified)

Single-button AI summarization with model selection dropdown

目前為 2025-02-16 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 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/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}">&times;</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}">&times;</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()
})()