A browser extension that lets you summarize any webpage and ask questions using AI.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Harden rendering and switch to on-demand extraction.

Sanitize markdown output, inject content extraction scripts only when needed across Chrome/Firefox, and clarify privacy behavior while preserving dynamic API endpoint compatibility.

Made-with: Cursor

+145 -77
+9 -3
README.md
··· 8 8 9 9 - ๐Ÿ“„ **One-Click Summaries** - Summarize page content efficently 10 10 - ๐Ÿค– **Follow Up Questions** - Ask questions about the current webpage and summary 11 - - ๐Ÿ”Œ **OpenAI-Compatible** - Works with Ollama, OpenAI, Groq, LM Studio, and more 11 + - ๐Ÿ”Œ **OpenAI-Compatible** - Works with Ollama, OpenAI, Groq, and LM Studio 12 12 - โš™๏ธ **Configurable** - Choose your own model and API endpoint 13 - - ๐Ÿ”’ **Private** - All processing happens where you want it, including the option of running it locally with Ollama. Data never gets sent to servers. 13 + - ๐Ÿ”’ **Privacy-first** - Keep summaries local with Ollama, or use a cloud provider when you choose one. 14 14 - โŒจ๏ธ **Quick access** - Keyboard shortcut of `Ctrl/Cmd+Shift+U`, use the right click menu, or click the extension icon. 15 15 16 16 ## About ··· 72 72 - Recommended for local Ollama installations 73 73 - Base URL should NOT include `/v1` 74 74 75 - ### OpenAI-Compatible Mode 75 + ### OpenAI-Compatible Mode 76 76 - Uses the `/v1/chat/completions` endpoint 77 77 - Required for OpenAI, Groq, and other cloud providers 78 78 - Can work with Ollama if you set the `OPENAI_API_BASE` environment variable 79 79 - Base URL MUST include `/v1` for Ollama compatibility mode 80 + 81 + ## Privacy Notes 82 + 83 + - In **Ollama Native** mode, your content stays on your machine (local model/server). 84 + - In **OpenAI-compatible** mode, page content is sent to the provider endpoint you configure. 85 + - The extension now extracts page text on-demand when you trigger summarize/chat, rather than running a persistent content script on all pages. 80 86 81 87 ## Troubleshooting 82 88
-6
manifest-chrome.json
··· 46 46 "background": { 47 47 "service_worker": "scripts/background.js" 48 48 }, 49 - "content_scripts": [ 50 - { 51 - "matches": ["<all_urls>"], 52 - "js": ["scripts/content.js"] 53 - } 54 - ], 55 49 "options_page": "options/options.html", 56 50 "icons": { 57 51 "16": "icons/icon16.png",
-6
manifest-firefox.json
··· 52 52 "background": { 53 53 "scripts": ["scripts/background.js"] 54 54 }, 55 - "content_scripts": [ 56 - { 57 - "matches": ["<all_urls>"], 58 - "js": ["scripts/content.js"] 59 - } 60 - ], 61 55 "options_page": "options/options.html", 62 56 "icons": { 63 57 "16": "icons/icon16.png",
-6
manifest.json
··· 46 46 "background": { 47 47 "service_worker": "scripts/background.js" 48 48 }, 49 - "content_scripts": [ 50 - { 51 - "matches": ["<all_urls>"], 52 - "js": ["scripts/content.js"] 53 - } 54 - ], 55 49 "options_page": "options/options.html", 56 50 "icons": { 57 51 "16": "icons/icon16.png",
+80 -56
popup/popup.js
··· 52 52 const DETAILED_SUMMARY_CACHE_PREFIX = "detailed_summary_cache_"; 53 53 const CONTENT_CACHE_PREFIX = "content_cache_"; 54 54 const CHAT_CACHE_PREFIX = "chat_cache_"; 55 - const TRUNCATION_CACHE_PREFIX = "truncation_cache_"; 56 55 const SUGGESTIONS_CACHE_PREFIX = "suggestions_cache_"; 57 56 58 57 // โ”€โ”€ Prompts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ··· 63 62 const QUICK_SUMMARY_PROMPT = `Please provide a "Quick Summary" of this webpage. Focus on the main points and key takeaways. Use markdown formatting (headings, bullet points, etc.). 64 63 65 64 The "Quick Summary" should be 3-5 **short** one-sentence bullet points. Each of these bullet points should have key points/takeaways **bolded** so people can quickly scan.`; 65 + 66 + const API_SETTINGS_DEFAULTS = { 67 + apiMode: "ollama", 68 + apiBaseUrl: "http://localhost:11434", 69 + model: "gpt-oss:20b-cloud", 70 + apiKey: "", 71 + disableThinking: false, 72 + }; 73 + 74 + async function getApiSettings() { 75 + return chrome.storage.sync.get(API_SETTINGS_DEFAULTS); 76 + } 66 77 67 78 // โ”€โ”€ Theme logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 68 79 // Cycles: light โ†’ dark โ†’ system ··· 282 293 summarizeBtn.disabled = true; 283 294 setSummarizeLabel("Loading..."); 284 295 285 - // Check for truncation cache 286 - const cachedTruncation = await chrome.storage.session.get([ 287 - TRUNCATION_CACHE_PREFIX + currentTabId, 288 - ]); 289 - 290 296 // If we have cached content for this tab, restore it 291 297 if (cachedContent && cachedContent.url === currentTabUrl) { 292 298 currentPageContent = cachedContent.content; ··· 395 401 396 402 async function extractPageContent() { 397 403 try { 398 - // Use currentTabId which was determined at popup load 399 - // This works for both Chrome popup and Firefox popup window 400 404 if (!currentTabId) { 401 405 currentPageContent = ""; 402 406 contentWasTruncated = false; 403 407 return; 404 408 } 405 - 406 - const tab = await chrome.tabs.get(currentTabId); 407 - 408 - if ( 409 - !tab.url || 410 - tab.url.startsWith("chrome://") || 411 - tab.url.startsWith("chrome-extension://") || 412 - tab.url.startsWith("edge://") || 413 - tab.url.startsWith("about:") || 414 - tab.url.startsWith("moz-extension://") || 415 - tab.url.startsWith("resource://") 416 - ) { 417 - currentPageContent = ""; 418 - contentWasTruncated = false; 419 - return; 420 - } 421 - const response = await chrome.tabs.sendMessage(tab.id, { 422 - action: "extract", 409 + 410 + const response = await chrome.runtime.sendMessage({ 411 + action: "extractPageContent", 412 + tabId: currentTabId, 423 413 }); 424 - if (response && response.content) { 414 + 415 + if (response && response.success && response.content) { 425 416 currentPageContent = response.content; 426 417 contentWasTruncated = response.wasTruncated || false; 427 418 // Cache the content with truncation info ··· 680 671 contentContainer.scrollTop = contentContainer.scrollHeight; 681 672 682 673 try { 683 - const settings = await chrome.storage.sync.get({ 684 - apiMode: "ollama", 685 - apiBaseUrl: "http://localhost:11434", 686 - model: "gpt-oss:20b-cloud", 687 - apiKey: "", 688 - disableThinking: false, 689 - }); 674 + const settings = await getApiSettings(); 690 675 691 676 // Increased context limit for LLM (was 6000, now 12000) 692 677 const pageContentForLLM = currentPageContent.substring(0, 12000); ··· 809 794 `; 810 795 811 796 try { 812 - const settings = await chrome.storage.sync.get({ 813 - apiMode: "ollama", 814 - apiBaseUrl: "http://localhost:11434", 815 - model: "gpt-oss:20b-cloud", 816 - apiKey: "", 817 - disableThinking: false, 818 - }); 797 + const settings = await getApiSettings(); 819 798 820 799 // Increased context limit for LLM (was 8000, now 15000) 821 800 const pageContentForLLM = currentPageContent.substring(0, 15000); ··· 932 911 }, 15000); // 15 second timeout 933 912 934 913 try { 935 - const settings = await chrome.storage.sync.get({ 936 - apiMode: "ollama", 937 - apiBaseUrl: "http://localhost:11434", 938 - model: "gpt-oss:20b-cloud", 939 - apiKey: "", 940 - disableThinking: false, 941 - }); 914 + const settings = await getApiSettings(); 942 915 943 916 const apiMessages = [ 944 917 { role: "system", content: SYSTEM_PROMPT_SUMMARIZER }, ··· 1059 1032 }, 15000); 1060 1033 1061 1034 try { 1062 - const settings = await chrome.storage.sync.get({ 1063 - apiMode: "ollama", 1064 - apiBaseUrl: "http://localhost:11434", 1065 - model: "gpt-oss:20b-cloud", 1066 - apiKey: "", 1067 - disableThinking: false, 1068 - }); 1035 + const settings = await getApiSettings(); 1069 1036 1070 1037 const apiMessages = [ 1071 1038 { role: "system", content: SYSTEM_PROMPT_CHAT }, ··· 1142 1109 return escapeHtml(text).replace(/\n/g, "<br>"); 1143 1110 } 1144 1111 try { 1145 - return marked.parse(text, { 1112 + const html = marked.parse(text, { 1146 1113 breaks: true, 1147 1114 gfm: true, 1148 1115 headerIds: false, 1149 1116 mangle: false, 1150 1117 }); 1118 + return sanitizeHtml(html); 1151 1119 } catch (e) { 1152 1120 return escapeHtml(text).replace(/\n/g, "<br>"); 1153 1121 } 1122 + } 1123 + 1124 + function sanitizeHtml(html) { 1125 + const template = document.createElement("template"); 1126 + template.innerHTML = html; 1127 + 1128 + const allowedTags = new Set([ 1129 + "P", "BR", "HR", "H1", "H2", "H3", "H4", "H5", "H6", 1130 + "UL", "OL", "LI", "STRONG", "EM", "CODE", "PRE", 1131 + "BLOCKQUOTE", "A", "TABLE", "THEAD", "TBODY", "TR", "TH", "TD", 1132 + ]); 1133 + 1134 + const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT); 1135 + const toReplace = []; 1136 + const toClean = []; 1137 + 1138 + while (walker.nextNode()) { 1139 + const el = walker.currentNode; 1140 + if (!allowedTags.has(el.tagName)) { 1141 + toReplace.push(el); 1142 + continue; 1143 + } 1144 + toClean.push(el); 1145 + } 1146 + 1147 + for (const el of toReplace) { 1148 + const safeText = document.createTextNode(el.textContent || ""); 1149 + el.replaceWith(safeText); 1150 + } 1151 + 1152 + for (const el of toClean) { 1153 + const attrs = [...el.attributes]; 1154 + for (const attr of attrs) { 1155 + const name = attr.name.toLowerCase(); 1156 + const value = attr.value || ""; 1157 + if (name.startsWith("on")) { 1158 + el.removeAttribute(attr.name); 1159 + continue; 1160 + } 1161 + if (el.tagName === "A" && name === "href") { 1162 + if (!value.startsWith("http://") && !value.startsWith("https://")) { 1163 + el.removeAttribute("href"); 1164 + } else { 1165 + el.setAttribute("target", "_blank"); 1166 + el.setAttribute("rel", "noopener noreferrer"); 1167 + } 1168 + continue; 1169 + } 1170 + if (el.tagName === "A" && (name === "target" || name === "rel")) { 1171 + continue; 1172 + } 1173 + el.removeAttribute(attr.name); 1174 + } 1175 + } 1176 + 1177 + return template.innerHTML; 1154 1178 } 1155 1179 1156 1180 function setLoading(loading) {
+51
scripts/background.js
··· 105 105 return true; 106 106 } 107 107 108 + if (request.action === "extractPageContent") { 109 + extractPageContentForTab(request.tabId) 110 + .then((result) => sendResponse({ success: true, ...result })) 111 + .catch((error) => { 112 + sendResponse({ 113 + success: false, 114 + error: error.message || "Failed to extract page content", 115 + }); 116 + }); 117 + return true; 118 + } 119 + 108 120 if (request.action === "chat") { 109 121 handleChatRequest(request.data) 110 122 .then((response) => { ··· 140 152 return true; 141 153 } 142 154 }); 155 + 156 + function isRestrictedUrl(url) { 157 + return ( 158 + !url || 159 + url.startsWith("chrome://") || 160 + url.startsWith("chrome-extension://") || 161 + url.startsWith("edge://") || 162 + url.startsWith("about:") || 163 + url.startsWith("moz-extension://") || 164 + url.startsWith("resource://") 165 + ); 166 + } 167 + 168 + async function extractPageContentForTab(tabId) { 169 + if (!tabId) { 170 + return { content: "", wasTruncated: false }; 171 + } 172 + 173 + const tab = await chrome.tabs.get(tabId); 174 + if (isRestrictedUrl(tab.url)) { 175 + return { content: "", wasTruncated: false }; 176 + } 177 + 178 + // Inject extractor only when needed to avoid running on all pages by default. 179 + await chrome.scripting.executeScript({ 180 + target: { tabId }, 181 + files: ["scripts/content.js"], 182 + }); 183 + 184 + const response = await chrome.tabs.sendMessage(tabId, { action: "extract" }); 185 + if (!response || !response.content) { 186 + return { content: "", wasTruncated: false }; 187 + } 188 + 189 + return { 190 + content: response.content, 191 + wasTruncated: Boolean(response.wasTruncated), 192 + }; 193 + } 143 194 144 195 async function testOllamaConnection() { 145 196 const response = await fetch("http://localhost:11434/api/tags");
+5
scripts/content.js
··· 2 2 3 3 (function() { 4 4 'use strict'; 5 + 6 + if (window.__webaiExtractorInstalled) { 7 + return; 8 + } 9 + window.__webaiExtractorInstalled = true; 5 10 6 11 // Tags to extract text from - be more inclusive 7 12 const CONTENT_TAGS = [