Summarize with AI (Unified)

Single-button AI summarization with model selection dropdown

当前为 2025-02-14 提交的版本,查看 最新版本

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