Greasy Fork 还支持 简体中文。

Summarize with AI (Unified)

Single-button AI summarization with model selection dropdown

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

  1. // ==UserScript==
  2. // @name Summarize with AI (Unified)
  3. // @namespace https://github.com/insign/summarize-with-ai
  4. // @version 2025.02.16.14.56
  5. // @description Single-button AI summarization with model selection dropdown
  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. const BUTTON_ID = 'summarize-button'
  23. const DROPDOWN_ID = 'model-dropdown'
  24. const OVERLAY_ID = 'summarize-overlay'
  25. const CLOSE_BUTTON_ID = 'summarize-close'
  26. const CONTENT_ID = 'summarize-content'
  27. const ERROR_ID = 'summarize-error'
  28.  
  29. const MODEL_GROUPS = {
  30. openai : {
  31. name: 'OpenAI', models: [ 'gpt-4o-mini', 'o3-mini' ], baseUrl: 'https://api.openai.com/v1/chat/completions',
  32. }, gemini: {
  33. name : 'Gemini', models: [
  34. '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',
  35. 'gemini-2.0-flash-lite-preview-02-05',
  36. ], baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models/',
  37. },
  38. }
  39.  
  40. const PROMPT_TEMPLATE = (title, content, lang) => `You are a helpful assistant that provides clear and affirmative explanations of content.
  41. Generate a concise summary that includes:
  42. - 2-sentence introduction
  43. - Bullet points with relevant emojis
  44. - No section headers
  45. - Use HTML formatting, but send withouy \`\`\`html markdown syntax since it well be injected into the page to the browser evaluate correctly
  46. - After the last bullet point add a 2-sentence conclusion using opinionated language your general knowledge
  47. - Language: ${lang}
  48.  
  49. Article Title: ${title}
  50. Article Content: ${content}`
  51.  
  52. let activeModel = 'gpt-4o-mini'
  53. let articleData = null
  54.  
  55. function initialize() {
  56. document.addEventListener('keydown', handleKeyPress)
  57. articleData = getArticleData()
  58. if (articleData) {
  59. addSummarizeButton()
  60. showElement(BUTTON_ID)
  61. setupFocusListeners() // Movido para dentro do bloco condicional
  62. }
  63. }
  64.  
  65. function getArticleData() {
  66. try {
  67. const docClone = document.cloneNode(true)
  68. docClone.querySelectorAll('script, style').forEach(el => el.remove())
  69. if (!isProbablyReaderable(docClone)) return null
  70. const reader = new Readability(docClone)
  71. const article = reader.parse()
  72. return article?.content ? { title: article.title, content: article.textContent } : null
  73. }
  74. catch (error) {
  75. console.error('Article parsing failed:', error)
  76. return null
  77. }
  78. }
  79.  
  80. function addSummarizeButton() {
  81. if (document.getElementById(BUTTON_ID)) return
  82. const button = document.createElement('div')
  83. button.id = BUTTON_ID
  84. button.textContent = 'S'
  85. document.body.appendChild(button)
  86. const dropdown = createDropdown()
  87. document.body.appendChild(dropdown)
  88. button.addEventListener('click', toggleDropdown)
  89. button.addEventListener('dblclick', handleApiKeyReset)
  90. injectStyles()
  91. }
  92.  
  93. function createDropdown() {
  94. const dropdown = document.createElement('div')
  95. dropdown.id = DROPDOWN_ID
  96. dropdown.style.display = 'none'
  97. Object.entries(MODEL_GROUPS).forEach(([ service, group ]) => {
  98. const groupDiv = document.createElement('div')
  99. groupDiv.className = 'model-group'
  100. groupDiv.appendChild(createHeader(group.name))
  101. group.models.forEach(model => groupDiv.appendChild(createModelItem(model)))
  102. dropdown.appendChild(groupDiv)
  103. })
  104. return dropdown
  105. }
  106.  
  107. function createHeader(text) {
  108. const header = document.createElement('div')
  109. header.className = 'group-header'
  110. header.textContent = text
  111. return header
  112. }
  113.  
  114. function createModelItem(model) {
  115. const item = document.createElement('div')
  116. item.className = 'model-item'
  117. item.textContent = model
  118. item.addEventListener('click', () => {
  119. activeModel = model
  120. hideElement(DROPDOWN_ID)
  121. processSummarization()
  122. })
  123. return item
  124. }
  125.  
  126. async function processSummarization() {
  127. try {
  128. const service = getCurrentService()
  129. const apiKey = await getApiKey(service)
  130. if (!apiKey) return
  131. showSummaryOverlay('<p class="glow">Summarizing...</p>')
  132. const payload = { title: articleData.title, content: articleData.content, lang: navigator.language || 'en-US' }
  133. const response = await sendApiRequest(service, apiKey, payload)
  134. handleApiResponse(response, service)
  135. }
  136. catch (error) {
  137. showErrorNotification(`Error: ${error.message}`)
  138. }
  139. }
  140.  
  141. async function sendApiRequest(service, apiKey, payload) {
  142. const url = service === 'openai' ? MODEL_GROUPS.openai.baseUrl : `${MODEL_GROUPS.gemini.baseUrl}${activeModel}:generateContent?key=${apiKey}`
  143. return new Promise((resolve, reject) => {
  144. GM.xmlHttpRequest({
  145. method : 'POST',
  146. url,
  147. headers: getHeaders(service, apiKey),
  148. data : JSON.stringify(buildRequestBody(service, payload)),
  149. onload : resolve,
  150. onerror: reject,
  151. onabort: () => reject(new Error('Request aborted')),
  152. })
  153. })
  154. }
  155.  
  156. function handleApiResponse(response, service) {
  157. if (response.status !== 200) {
  158. throw new Error(`API Error (${response.status}): ${response.statusText}`)
  159. }
  160. const data = JSON.parse(response.responseText)
  161. const summary = service === 'openai' ? data.choices[0].message.content : data.candidates[0].content.parts[0].text
  162. updateSummaryOverlay(summary.replace(/\n/g, '<br>'))
  163. }
  164.  
  165. function buildRequestBody(service, { title, content, lang }) {
  166. return service === 'openai' ? {
  167. model : activeModel, messages: [
  168. {
  169. role: 'system', content: PROMPT_TEMPLATE(title, content, lang),
  170. }, {
  171. role: 'user', content: 'Generate summary',
  172. },
  173. ], temperature: 0.5, max_tokens: 500,
  174. } : {
  175. contents: [
  176. {
  177. parts: [
  178. {
  179. text: PROMPT_TEMPLATE(title, content, lang),
  180. },
  181. ],
  182. },
  183. ],
  184. }
  185. }
  186.  
  187. function getHeaders(service, apiKey) {
  188. return service === 'openai' ? {
  189. 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`,
  190. } : { 'Content-Type': 'application/json' }
  191. }
  192.  
  193. function getCurrentService() {
  194. return Object.keys(MODEL_GROUPS).find(service => MODEL_GROUPS[service].models.includes(activeModel))
  195. }
  196.  
  197. function toggleDropdown(e) {
  198. e.stopPropagation()
  199. const dropdown = document.getElementById(DROPDOWN_ID)
  200. dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'
  201. }
  202.  
  203. function handleKeyPress(e) {
  204. if (e.altKey && e.code === 'KeyS') {
  205. e.preventDefault()
  206. document.getElementById(BUTTON_ID)?.click()
  207. }
  208. }
  209.  
  210. async function getApiKey(service) {
  211. const storageKey = `${service}_api_key`
  212. let apiKey = await GM.getValue(storageKey)
  213. if (!apiKey) {
  214. apiKey = prompt(`Enter ${service.toUpperCase()} API key:`)
  215. if (apiKey) await GM.setValue(storageKey, apiKey.trim())
  216. }
  217. return apiKey?.trim()
  218. }
  219.  
  220. function handleApiKeyReset() {
  221. const service = prompt('Reset API key for (openai/gemini):').toLowerCase()
  222. if (MODEL_GROUPS[service]) {
  223. const newKey = prompt(`Enter new ${service} API key:`)
  224. if (newKey) GM.setValue(`${service}_api_key`, newKey.trim())
  225. }
  226. }
  227.  
  228. function injectStyles() {
  229. GM.addStyle(`
  230. #${BUTTON_ID} {
  231. position: fixed;
  232. bottom: 20px;
  233. right: 20px;
  234. width: 60px;
  235. height: 60px;
  236. background: #2563eb;
  237. color: white;
  238. font-size: 28px;
  239. font-family: sans-serif;
  240. border-radius: 50%;
  241. cursor: pointer;
  242. z-index: 99999;
  243. box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  244. display: flex !important;
  245. align-items: center !important;
  246. justify-content: center !important;
  247. transition: transform 0.2s;
  248. line-height: 1;
  249. }
  250. #${DROPDOWN_ID} {
  251. position: fixed;
  252. bottom: 90px;
  253. right: 20px;
  254. background: white;
  255. border-radius: 8px;
  256. box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  257. z-index: 100000;
  258. max-height: 60vh;
  259. overflow-y: auto;
  260. padding: 12px;
  261. width: 280px;
  262. font-family: sans-serif;
  263. }
  264. .model-group { margin: 8px 0; }
  265. .group-header {
  266. padding: 8px 12px;
  267. font-weight: 600;
  268. color: #4b5563;
  269. background: #f3f4f6;
  270. border-radius: 4px;
  271. margin-bottom: 6px;
  272. font-family: sans-serif;
  273. }
  274. .model-item {
  275. padding: 10px 16px;
  276. margin: 4px 0;
  277. border-radius: 6px;
  278. transition: background 0.2s;
  279. font-size: 14px;
  280. font-family: sans-serif;
  281. cursor: pointer;
  282. }
  283. .model-item:hover { background: #1143b2; }
  284. #${OVERLAY_ID} {
  285. position: fixed;
  286. top: 0;
  287. left: 0;
  288. width: 100%;
  289. height: 100%;
  290. background-color: rgba(0, 0, 0, 0.5);
  291. z-index: 100000;
  292. display: flex;
  293. align-items: center;
  294. justify-content: center;
  295. overflow: auto;
  296. font-family: sans-serif;
  297. }
  298. #${CONTENT_ID} {
  299. background-color: #fff;
  300. padding: 30px;
  301. border-radius: 10px;
  302. box-shadow: 0 0 15px rgba(0,0,0,0.5);
  303. max-width: 700px;
  304. max-height: 90%;
  305. overflow: auto;
  306. position: relative;
  307. font-size: 1.2em;
  308. color: #000;
  309. font-family: sans-serif;
  310. }
  311. #${ERROR_ID} {
  312. position: fixed;
  313. bottom: 20px;
  314. left: 20px;
  315. background-color: rgba(255,0,0,0.8);
  316. color: white;
  317. padding: 10px 20px;
  318. border-radius: 5px;
  319. z-index: 100001;
  320. font-size: 14px;
  321. font-family: sans-serif;
  322. }
  323. .glow {
  324. font-size: 1.5em;
  325. color: #333;
  326. text-align: center;
  327. animation: glow 2s ease-in-out infinite alternate;
  328. font-family: sans-serif;
  329. }
  330. @keyframes glow {
  331. from { color: #4b6cb7; text-shadow: 0 0 10px #4b6cb7; }
  332. to { color: #182848; text-shadow: 0 0 20px #8e2de2; }
  333. }
  334. `)
  335. }
  336.  
  337. function showSummaryOverlay(content) {
  338. if (document.getElementById(OVERLAY_ID)) {
  339. updateSummaryOverlay(content)
  340. return
  341. }
  342. const overlay = document.createElement('div')
  343. overlay.id = OVERLAY_ID
  344. overlay.innerHTML = `
  345. <div id="${CONTENT_ID}">
  346. <div id="${CLOSE_BUTTON_ID}">&times;</div>
  347. ${content}
  348. </div>
  349. `
  350. document.body.appendChild(overlay)
  351. document.body.style.overflow = 'hidden'
  352. document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
  353. overlay.addEventListener('click', (e) => {
  354. if (!e.target.closest(`#${CONTENT_ID}`)) closeOverlay()
  355. })
  356. document.addEventListener('keydown', (e) => {
  357. if (e.key === 'Escape') closeOverlay()
  358. })
  359.  
  360. function closeOverlay() {
  361. document.getElementById(OVERLAY_ID)?.remove()
  362. document.body.style.overflow = ''
  363. }
  364. }
  365.  
  366. function updateSummaryOverlay(content) {
  367. const contentDiv = document.getElementById(CONTENT_ID)
  368. if (contentDiv) {
  369. contentDiv.innerHTML = `<div id="${CLOSE_BUTTON_ID}">&times;</div>${content}`
  370. document.getElementById(CLOSE_BUTTON_ID).addEventListener('click', closeOverlay)
  371. }
  372.  
  373. function closeOverlay() {
  374. document.getElementById(OVERLAY_ID)?.remove()
  375. document.body.style.overflow = ''
  376. }
  377. }
  378.  
  379. function showErrorNotification(message) {
  380. if (document.getElementById(ERROR_ID)) {
  381. document.getElementById(ERROR_ID).innerText = message
  382. return
  383. }
  384. const errorDiv = document.createElement('div')
  385. errorDiv.id = ERROR_ID
  386. errorDiv.innerText = message
  387. document.body.appendChild(errorDiv)
  388. setTimeout(() => errorDiv.remove(), 4000)
  389. }
  390.  
  391. function hideElement(id) {
  392. const el = document.getElementById(id)
  393. if (el) el.style.display = 'none'
  394. }
  395.  
  396. function showElement(id) {
  397. const el = document.getElementById(id)
  398. if (el) el.style.display = 'block'
  399. }
  400.  
  401. function setupFocusListeners() {
  402. document.addEventListener('focusin', toggleButtonVisibility)
  403. document.addEventListener('focusout', toggleButtonVisibility)
  404. }
  405.  
  406. function toggleButtonVisibility() {
  407. const button = document.getElementById(BUTTON_ID)
  408. if (!button) return // Previne erro em páginas sem botão
  409.  
  410. const active = document.activeElement
  411. const isInput = active?.matches('input, textarea, select, [contenteditable]')
  412. button.style.display = isInput ? 'none' : 'block'
  413. }
  414.  
  415. initialize()
  416. })()