Greasy Fork is available in English.
Summarize webpage or selected text via remote API key
// ==UserScript==
// @name AI Page Summarizer
// @version 1.3.4
// @description Summarize webpage or selected text via remote API key
// @author SH3LL
// @match *://*/*
// @grant GM.xmlHttpRequest
// @grant GM.setValue
// @grant GM.getValue
// @run-at document-end
// @namespace http://tampermonkey.net/
// ==/UserScript==
(function () {
'use strict';
// === State ===
let API_KEYS = []; // Will be loaded from storage
let loading = false;
let isSidebarVisible = false;
let isSettingsVisible = false;
let rateLimitRemaining = 0;
let rateLimitInterval = null;
const browserLanguage = navigator.language;
const selectedLanguage = new Intl.DisplayNames([browserLanguage], { type: 'language' }).of(browserLanguage) || browserLanguage;
// === YouTube Transcript State ===
let youtubeTranscript = null;
let styleElement = null;
const hideCSS = `
ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"] {
opacity: 0 !important;
z-index: -9999 !important;
}
`;
// === Models ===
const models = [
{ label: 'gpt-oss-120b', model: 'gpt-oss-120b' },
{ label: 'gpt-5-nano', model: 'gpt-5-nano' },
{ label: 'gemini-2.5-pro', model: 'gemini-2.5-pro' },
{ label: 'claude-sonnet-4.6', model: 'claude-sonnet-4.6' },
];
let selectedModel = models[0].model;
// === Init UI ===
const {
shadowRoot, sidebar, toggleButton, summarizeButton,
statusDisplay, summaryContainer, modelSelect, rateLimitDisplay,
settingsButton, settingsContainer, apiKeyInput, saveKeysButton
} = createSidebarUI();
document.body.appendChild(shadowRoot.host);
// === Load Keys on Start ===
loadApiKeys();
// === Fade timeout ===
let hoverTimeout;
setTimeout(() => toggleButton.style.opacity = '0.3', 1000);
toggleButton.addEventListener('mouseover', () => {
clearTimeout(hoverTimeout);
toggleButton.style.opacity = '1';
});
toggleButton.addEventListener('mouseout', () => {
hoverTimeout = setTimeout(() => {
if (!isSidebarVisible) toggleButton.style.opacity = '0.3';
}, 2000);
});
// === Event Listeners ===
document.addEventListener('mouseup', updateButtonText);
toggleButton.addEventListener('click', toggleSidebar);
summarizeButton.addEventListener('click', handleSummarizeClick);
settingsButton.addEventListener('click', toggleSettings);
saveKeysButton.addEventListener('click', saveApiKeysFromInput);
modelSelect.addEventListener('change', e => {
selectedModel = models[e.target.value].model;
updateStatus('Idle', '#555');
});
updateButtonText();
updateStatus('Idle', '#555');
// === Key Management Functions ===
async function loadApiKeys() {
const storedKeys = await GM.getValue('saved_api_keys', '');
if (storedKeys) {
API_KEYS = storedKeys.split(',').map(k => k.trim()).filter(k => k.length > 0);
updateStatus(`Loaded ${API_KEYS.length} keys`, '#0c6');
apiKeyInput.value = API_KEYS.join('\n'); // Show in settings for editing
} else {
updateStatus('No API Keys!', '#f55');
// Auto open settings if no keys
setTimeout(() => {
if(!isSidebarVisible) toggleSidebar();
if(!isSettingsVisible) toggleSettings();
}, 500);
}
}
async function saveApiKeysFromInput() {
const text = apiKeyInput.value;
// Split by newlines or commas
const keys = text.split(/[\n,]/).map(k => k.trim()).filter(k => k.length > 5);
if (keys.length === 0) {
alert("Please enter at least one valid API Key.");
return;
}
await GM.setValue('saved_api_keys', keys.join(','));
API_KEYS = keys;
updateStatus(`Saved ${keys.length} keys`, '#0c6');
toggleSettings(); // Close settings
}
function getRandomApiKey() {
if (!API_KEYS || API_KEYS.length === 0) return null;
const randomArray = new Uint32Array(1);
crypto.getRandomValues(randomArray);
const randomIndex = randomArray[0] % API_KEYS.length;
console.log(`🔑 Using API key index: ${randomIndex + 1}`);
return API_KEYS[randomIndex];
}
// === YouTube Transcript Functions ===
function hidePanel() {
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.textContent = hideCSS;
document.head.appendChild(styleElement);
}
}
function showPanel() {
if (styleElement) {
styleElement.remove();
styleElement = null;
}
}
function waitForSegments(timeout = 20000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const check = () => {
const segments = document.querySelectorAll('ytd-transcript-segment-renderer yt-formatted-string.segment-text');
if (segments.length > 0) {
resolve(segments);
return;
}
if (Date.now() - startTime > timeout) {
reject(new Error("Timeout: segmenti non caricati"));
return;
}
setTimeout(check, 200);
};
check();
});
}
async function extractTranscript() {
console.log("👻 Avvio estrazione trascrizione YouTube...");
const moreButton = document.querySelector('tp-yt-paper-button#expand');
if (moreButton) {
moreButton.click();
await new Promise(r => setTimeout(r, 500));
}
let transcriptBtn = null;
const allButtons = document.querySelectorAll('button');
for (const btn of allButtons) {
const text = btn.textContent.toLowerCase();
if (text.includes('trascrizione') || text.includes('transcript')) {
transcriptBtn = btn;
break;
}
}
if (!transcriptBtn) {
console.log("❌ Pulsante trascrizione non trovato.");
return null;
}
transcriptBtn.click();
await new Promise(r => setTimeout(r, 100));
hidePanel();
try {
const segments = await waitForSegments(20000);
let fullText = "";
segments.forEach(el => {
fullText += el.textContent.trim() + " ";
});
fullText = fullText.replace(/\s+/g, ' ').trim();
const closeBtn = document.querySelector(
'[target-id="engagement-panel-searchable-transcript"] #visibility-button button, ' +
'[target-id="engagement-panel-searchable-transcript"] button[aria-label*="Chiudi"], ' +
'[target-id="engagement-panel-searchable-transcript"] button[aria-label*="Close"]'
);
if (closeBtn) {
closeBtn.click();
}
showPanel();
return fullText;
} catch (err) {
showPanel();
console.error("❌", err.message);
return null;
}
}
async function initYouTubeTranscript() {
youtubeTranscript = null;
updateButtonText();
updateStatus('Transcript...', '#f90');
const transcript = await extractTranscript();
if (transcript) {
youtubeTranscript = transcript;
updateButtonText();
}
updateStatus('Idle', '#555');
}
function isYouTubeVideoPage() {
return window.location.hostname.includes('youtube.com') && window.location.pathname === '/watch';
}
// === RateLimit Timer Functions ===
function startRateLimitTimer() {
rateLimitRemaining = 61;
updateRateLimitDisplay();
if (rateLimitInterval) clearInterval(rateLimitInterval);
rateLimitInterval = setInterval(() => {
rateLimitRemaining--;
updateRateLimitDisplay();
if (rateLimitRemaining <= 0) {
clearInterval(rateLimitInterval);
rateLimitInterval = null;
}content = content.replace(/```json\n?|```\n?/g, '').replace(/}[^\]]*$/, '}').trim();
}, 1000);
}
function updateRateLimitDisplay() {
if (rateLimitRemaining > 0) {
rateLimitDisplay.textContent = `RateLimit: ${rateLimitRemaining}s`;
rateLimitDisplay.style.color = '#f5a623';
} else {
rateLimitDisplay.textContent = 'RateLimit: Ready';
rateLimitDisplay.style.color = '#0c6';
}
}
function isRateLimited() {
return rateLimitRemaining > 0;
}
// === Markdown Parser ===
function parseMarkdown(text) {
return text
.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/_(.+?)_/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
.replace(/^\s*[-*+] (.+)$/gm, '<li>$1</li>')
.replace(/^\s*\d+\. (.+)$/gm, '<li>$1</li>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
.replace(/\n/g, '<br>');
}
// === API Call ===
async function summarizePage(text, lang) {
const apiKey = getRandomApiKey();
if (!apiKey) {
return Promise.reject({ message: "No API Key found! Click ⚙️ to add keys." });
}
const prompt = `Summarize the following text in ${lang}. The summary is organised in blocks of topics. The summary must be concise.
Return the result in a json list composed of dictionaries with fields "title" (the title starts with a contextual colored emoji) and "text".
Don't add any other sentence like "Here is the summary".
Don't add any coding formatting/header like "\"\`\`\`json\".
Don't add any formatting to title or text, no formatting at all".
Exclude from the summary any advertisement, collaboration, promotion or sponsorization.
Here is the text: ${text}`;
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'POST',
url: 'https://api.airforce/v1/chat/completions',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
data: JSON.stringify({
model: selectedModel,
messages: [{ role: 'user', content: prompt }]
}),
onload: r => {
try {
const response = JSON.parse(r.responseText);
if (r.status >= 200 && r.status < 300 && response.choices?.[0]) {
let content = response.choices[0].message.content;
//content = content.replace(/```json\n?|```\n?/g, '').replace("\ndiscord.gg/airforce","").replace("\n\nWant best roleplay experience?\nhttps://llmplayground.net", "").trim();
content = content.replace(/```json\n?|```\n?/g, '').replace(/\][^\]]*$/, ']').trim();
resolve({ status: r.status, text: content, ok: true });
} else {
reject({ message: response.error?.message || `API Error: ${r.status}` });
}
} catch (e) {
reject({ message: `Parse error: ${e.message}` });
}
},
onerror: () => reject({ message: 'Network error' })
});
});
}
// === Handlers ===
function toggleSidebar() {
isSidebarVisible = !isSidebarVisible;
sidebar.style.transform = isSidebarVisible ? 'translateX(0)' : 'translateX(100%)';
toggleButton.style.right = isSidebarVisible ? '300px' : '0';
toggleButton.style.opacity = isSidebarVisible ? '1' : '0.3';
}
function toggleSettings() {
isSettingsVisible = !isSettingsVisible;
settingsContainer.style.display = isSettingsVisible ? 'flex' : 'none';
summaryContainer.style.display = isSettingsVisible ? 'none' : 'block';
}
function updateButtonText() {
const sel = window.getSelection().toString().trim();
if (sel) {
summarizeButton.textContent = `✨ "${sel.slice(0, 6)}..."`;
} else if (youtubeTranscript) {
summarizeButton.textContent = '📹️ Summary';
} else {
summarizeButton.textContent = '✨ Summary';
}
}
function updateStatus(text, color) {
statusDisplay.innerHTML = `<span style="color:${color}; margin-right: 4px;">● ${text}</span> - <span style="color:#0af;margin-left:4px">${selectedLanguage}</span>`;
}
function renderSummary(jsonText) {
const c = document.createElement('div');
try {
JSON.parse(jsonText).forEach(b => {
const card = document.createElement('div');
card.className = 'card';
const title = document.createElement('div');
title.className = 'card-title';
title.innerHTML = parseMarkdown(b.title);
const text = document.createElement('div');
text.className = 'card-text';
text.innerHTML = parseMarkdown(b.text);
card.append(title, text);
c.appendChild(card);
});
} catch (e) {
c.innerText = "Error parsing JSON response. Raw text:\n" + jsonText;
}
return c;
}
function handleSummarizeClick() {
if (API_KEYS.length === 0) {
alert("No API Keys configured. Please open Settings (⚙️) and add your keys.");
if(!isSettingsVisible) toggleSettings();
return;
}
if (isRateLimited()) {
updateStatus(`Wait RateLimit`, '#f5a623');
return;
}
if (loading) return;
const selectedText = window.getSelection().toString().trim();
let content;
if (selectedText) {
content = selectedText;
} else if (youtubeTranscript) {
content = youtubeTranscript;
} else {
content = document.body.innerText;
}
loading = true;
summarizeButton.disabled = true;
updateStatus('Loading...', '#f90');
summaryContainer.style.display = 'none';
settingsContainer.style.display = 'none'; // Ensure settings closed
isSettingsVisible = false;
summarizePage(content, selectedLanguage)
.then(({ status, text, ok }) => {
try {
summaryContainer.textContent = '';
summaryContainer.append(renderSummary(text));
updateStatus(`OK`, ok ? '#0c6' : '#fc0');
} catch (e) {
summaryContainer.innerHTML = `<div style="color:#f55;font-size:11px">⚠️ ${e.message}</div>`;
updateStatus('Error', '#f55');
}
})
.catch(e => {
summaryContainer.innerHTML = `<div style="color:#f55;font-size:11px">⚠️ ${e.message}</div>`;
updateStatus('Error', '#f55');
})
.finally(() => {
loading = false;
summarizeButton.disabled = false;
updateButtonText();
summaryContainer.style.display = 'block';
startRateLimitTimer();
});
}
// === UI Builder ===
function createSidebarUI() {
const host = document.createElement('div');
const root = host.attachShadow({ mode: 'open' });
const css = `
*{box-sizing:border-box;margin:0;padding:0}
/* Main Layout */
.sidebar{position:fixed;right:0;top:0;width:300px;height:100vh;background:#0a0a0a;color:#fff;padding:10px;z-index:999999;font-family:system-ui,sans-serif;display:flex;flex-direction:column;gap:8px;transform:translateX(100%);transition:transform .2s ease;border-left:1px solid #444}
.toggle{position:fixed;right:0;top:50%;transform:translateY(-50%);width:18px;height:48px;background:#151515;border:1px solid #444;border-right:none;border-radius:6px 0 0 6px;cursor:pointer;z-index:1000000;display:flex;align-items:center;justify-content:center;color:#555;font-size:14px;transition:right .2s,background .15s,opacity .3s;opacity:0.3}
.toggle:hover{background:#1a1a1a;color:#888;opacity:1}
/* Top Controls Row */
.row{display:flex;gap:6px;align-items:center}
/* Shared Styles for Uniform Height (30px) & Borders */
select, button.sum, button.icon-btn {
height:30px; border-radius:5px; border:1px solid #888; font-size:13px; cursor:pointer;
}
/* 1. Dropdown: Fills space, Gray BG */
select{flex:1;width:0;padding:0 8px;background:#111;color:#aaa;outline:none}
select:focus{border-color:#0af}
/* 2. Settings Btn: Compact, Dark Gray BG */
button.icon-btn{flex:0 0 auto;padding:0 10px;background:#333;color:#eee;display:inline-flex;align-items:center;justify-content:center}
/* 3. Summary Btn: Compact, Blue BG */
button.sum{flex:0 0 auto;width:auto;padding:0 10px;background:#0af;color:#000;font-weight:600;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;transition:opacity .15s}
button:hover{opacity:.85}
button:disabled{opacity:.4;cursor:not-allowed}
/* Status Bar */
.status-row{display:flex;justify-content:space-between;align-items:center;font-size:10px;padding:4px 0;border-bottom:1px solid #444}
.status{color:#444}
.ratelimit{color:#0c6;font-weight:500}
/* Main Content (Flex Fill) */
.summary{flex:1;overflow-y:auto;scrollbar-width:thin;scrollbar-color:#222 transparent;min-height:0}
/* Settings Panel (Flex Fill) */
.settings-panel{display:none;flex:1;flex-direction:column;gap:8px;border-top:1px solid #333;padding-top:10px;min-height:0}
.settings-label{font-size:12px;color:#888}
textarea.api-input{flex:1;width:100%;background:#111;color:#0f0;border:1px solid #444;padding:8px;font-family:monospace;font-size:11px;resize:none;border-radius:4px}
textarea.api-input:focus{outline:none;border-color:#0af}
/* Markdown & Cards */
.card{padding:4px 0;margin-bottom:6px;border-bottom:1px solid #444}
.card-title{font-weight:600;font-size:14px;color:#eee;margin-bottom:3px}
.card-text{color:#888;font-size:13px;line-height:1.4}
code{background:#1e1e1e;color:#e06c75;padding:1px 4px;border-radius:3px;font-family:'SF Mono',Consolas,monospace;font-size:12px}
pre{background:#1e1e1e;border-radius:4px;padding:8px;margin:4px 0;overflow-x:auto}
pre code{background:none;padding:0;color:#abb2bf;display:block;white-space:pre}
strong{color:#fff;font-weight:600}
em{color:#aaa;font-style:italic}
h2,h3,h4{color:#fff;margin:6px 0 4px}
li{margin-left:12px;list-style:disc;color:#888}
a{color:#0af;text-decoration:none}
`;
const style = document.createElement('style');
style.textContent = css;
const sidebar = document.createElement('div');
sidebar.className = 'sidebar';
const toggleButton = document.createElement('div');
toggleButton.className = 'toggle';
toggleButton.innerHTML = '✨';
// Top Row: Model | Settings | SummaryBtn
const row = document.createElement('div');
row.className = 'row';
const modelSelect = document.createElement('select');
models.forEach((m, i) => {
const o = document.createElement('option');
o.value = i;
o.textContent = m.label;
modelSelect.appendChild(o);
});
const settingsButton = document.createElement('button');
settingsButton.className = 'icon-btn';
settingsButton.textContent = '⚙️';
settingsButton.title = "Configure API Keys";
const summarizeButton = document.createElement('button');
summarizeButton.className = 'sum';
summarizeButton.textContent = '✨ Summary';
row.append(modelSelect, settingsButton, summarizeButton);
// Status Row
const statusRow = document.createElement('div');
statusRow.className = 'status-row';
const statusDisplay = document.createElement('div');
statusDisplay.className = 'status';
const rateLimitDisplay = document.createElement('div');
rateLimitDisplay.className = 'ratelimit';
rateLimitDisplay.textContent = 'RateLimit: Ready';
statusRow.append(statusDisplay, rateLimitDisplay);
// Summary Container
const summaryContainer = document.createElement('div');
summaryContainer.className = 'summary';
// Settings Container (Hidden by default)
const settingsContainer = document.createElement('div');
settingsContainer.className = 'settings-panel';
const settingsLabel = document.createElement('div');
settingsLabel.className = 'settings-label';
settingsLabel.textContent = 'Paste API Keys (one per line):';
const apiKeyInput = document.createElement('textarea');
apiKeyInput.className = 'api-input';
apiKeyInput.placeholder = "sk-...\nsk-...";
const saveKeysButton = document.createElement('button');
saveKeysButton.className = 'sum';
saveKeysButton.textContent = '💾 Save Keys';
saveKeysButton.style.marginTop = '8px';
settingsContainer.append(settingsLabel, apiKeyInput, saveKeysButton);
sidebar.append(row, statusRow, summaryContainer, settingsContainer);
root.append(style, sidebar, toggleButton);
return {
shadowRoot: root, sidebar, toggleButton, summarizeButton,
statusDisplay, summaryContainer, modelSelect, rateLimitDisplay,
settingsButton, settingsContainer, apiKeyInput, saveKeysButton
};
}
// === YouTube SPA Navigation Observer ===
if (isYouTubeVideoPage()) setTimeout(initYouTubeTranscript, 3000);
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
if (isYouTubeVideoPage()) {
youtubeTranscript = null;
updateButtonText();
setTimeout(initYouTubeTranscript, 3000);
} else {
youtubeTranscript = null;
updateButtonText();
}
}
}).observe(document, { subtree: true, childList: true });
})();