// ==UserScript== // @name TL;DR Summarizer (Facebook & Articles) // @namespace https://github.com/tldr-summarizer // @version 1.1.0 // @description Adds TL;DR buttons to Facebook posts and web articles. Summarizes using DeepSeek via Replicate API. // @author User // @match https://www.facebook.com/* // @match https://*/* // @exclude https://api.replicate.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect api.replicate.com // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ── Config ──────────────────────────────────────────────────────────────── const API_KEY_STORAGE = 'tldr_replicate_api_key'; const DEEPSEEK_MODEL = 'deepseek-ai/deepseek-r1'; let apiKey = GM_getValue(API_KEY_STORAGE, ''); GM_registerMenuCommand('⚙️ Set Replicate API Key', () => { const key = prompt('Enter your Replicate API key (get one at replicate.com):', apiKey); if (key !== null) { GM_setValue(API_KEY_STORAGE, key.trim()); apiKey = key.trim(); alert('✅ API key saved!'); } }); // ── Styles ──────────────────────────────────────────────────────────────── GM_addStyle(` .tldr-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 12px; margin: 4px 6px; background: #1877f2; color: #fff !important; border: none; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.15s, opacity 0.15s; font-family: inherit; line-height: 1.4; text-decoration: none; vertical-align: middle; } .tldr-btn:hover { background: #1464d0; } .tldr-btn:disabled { background: #999; cursor: not-allowed; opacity: 0.7; } .tldr-btn.article-btn { position: fixed; bottom: 28px; right: 28px; padding: 10px 20px; font-size: 13px; border-radius: 999px; box-shadow: 0 4px 18px rgba(0,0,0,0.28); z-index: 2147483646; } .tldr-modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); z-index: 2147483647; display: flex; align-items: center; justify-content: center; animation: tldr-fade-in 0.15s ease; } @keyframes tldr-fade-in { from { opacity: 0; } to { opacity: 1; } } .tldr-modal { background: #fff; border-radius: 14px; padding: 26px 30px 22px; max-width: 580px; width: 92vw; max-height: 72vh; overflow-y: auto; box-shadow: 0 12px 50px rgba(0,0,0,0.35); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; position: relative; animation: tldr-slide-in 0.15s ease; } @keyframes tldr-slide-in { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } } .tldr-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; } .tldr-modal-title { margin: 0; font-size: 15px; font-weight: 700; color: #1877f2; line-height: 1.3; } .tldr-modal-close { flex-shrink: 0; background: #f0f2f5; border: none; border-radius: 50%; width: 28px; height: 28px; font-size: 16px; cursor: pointer; color: #444; display: flex; align-items: center; justify-content: center; line-height: 1; transition: background 0.15s; margin-top: -2px; } .tldr-modal-close:hover { background: #dde1e7; } .tldr-modal-body { margin: 0; font-size: 14px; line-height: 1.65; color: #1c1e21; white-space: pre-wrap; word-break: break-word; } .tldr-modal-body ul { padding-left: 20px; margin: 6px 0; } .tldr-modal-body li { margin-bottom: 4px; } .tldr-spinner { display: inline-block; width: 13px; height: 13px; border: 2px solid rgba(255,255,255,0.35); border-top-color: #fff; border-radius: 50%; animation: tldr-spin 0.65s linear infinite; } @keyframes tldr-spin { to { transform: rotate(360deg); } } `); // ── UI helpers ──────────────────────────────────────────────────────────── function showModal(titleText, bodyText) { const overlay = document.createElement('div'); overlay.className = 'tldr-modal-overlay'; // Render bullet points nicely if body uses "- " or "• " lines let formattedBody = bodyText; overlay.innerHTML = `

📋 TL;DR — ${titleText}

${escapeHtml(formattedBody)}
`; overlay.querySelector('.tldr-modal-close').onclick = () => overlay.remove(); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', esc); } }); document.body.appendChild(overlay); } function escapeHtml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── API call ────────────────────────────────────────────────────────────── function summarize(rawText, btn, label) { if (!apiKey) { alert('⚠️ No API key set.\nClick the TL;DR Summarizer menu in your userscript manager and choose "Set Replicate API Key".'); return; } const origHTML = btn.innerHTML; btn.innerHTML = ' Summarizing…'; btn.disabled = true; // Trim text to keep API costs low; ~4000 chars ≈ ~1000 tokens const text = rawText.trim().slice(0, 4500); const prompt = `Summarize the following text in 2-3 sentences in English. Rules: - You MUST use the full name of every person mentioned — never say "the subject", "he", "she", or "an unnamed figure" without first stating their name. - Include what they did, when, and why it matters historically or contextually. - If the source text is in another language, still write the summary in English. - Do not use bullet points. Write a single flowing paragraph. - Start directly with the person's name or the topic — no preamble. Text: ${text} Summary:`; GM_xmlhttpRequest({ method: 'POST', url: `https://api.replicate.com/v1/models/${DEEPSEEK_MODEL}/predictions`, headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Prefer': 'wait' // Block until prediction completes (up to 60s) }, data: JSON.stringify({ input: { prompt, max_tokens: 600, temperature: 0.2, top_p: 0.9 } }), timeout: 90000, onload(resp) { btn.innerHTML = origHTML; btn.disabled = false; let summary = ''; try { const data = JSON.parse(resp.responseText); if (data.error) { showModal('Error', `Replicate error: ${data.error}`); return; } // Output is typically an array of token strings; join them if (Array.isArray(data.output)) { summary = data.output.join(''); } else if (typeof data.output === 'string') { summary = data.output; } else if (data.status && data.status !== 'succeeded') { // Prediction didn't finish in time — fall back to polling pollForResult(data.id, label, btn, origHTML); return; } // Strip DeepSeek R1 reasoning blocks summary = summary.replace(/[\s\S]*?<\/think>/gi, '').trim(); showModal(label, summary || '(No summary returned — the content may be too short or ambiguous.)'); } catch (e) { showModal('Parse Error', `Could not parse Replicate response.\n\nRaw: ${resp.responseText.slice(0, 300)}`); } }, onerror() { btn.innerHTML = origHTML; btn.disabled = false; showModal('Network Error', 'Could not reach api.replicate.com. Check your internet connection and API key.'); }, ontimeout() { btn.innerHTML = origHTML; btn.disabled = false; showModal('Timeout', 'The request timed out (>90s). Try again or summarize a shorter snippet.'); } }); } // Fallback polling when Prefer:wait doesn't return a completed prediction function pollForResult(predictionId, label, btn, origHTML) { const maxPolls = 20; let polls = 0; function poll() { if (polls++ > maxPolls) { btn.innerHTML = origHTML; btn.disabled = false; showModal('Timeout', 'Prediction is taking too long. Try again in a moment.'); return; } GM_xmlhttpRequest({ method: 'GET', url: `https://api.replicate.com/v1/predictions/${predictionId}`, headers: { 'Authorization': `Bearer ${apiKey}` }, onload(resp) { const data = JSON.parse(resp.responseText); if (data.status === 'succeeded') { btn.innerHTML = origHTML; btn.disabled = false; let summary = Array.isArray(data.output) ? data.output.join('') : (data.output || ''); summary = summary.replace(/[\s\S]*?<\/think>/gi, '').trim(); showModal(label, summary || '(Empty response)'); } else if (data.status === 'failed' || data.status === 'canceled') { btn.innerHTML = origHTML; btn.disabled = false; showModal('Error', `Prediction ${data.status}: ${data.error || 'unknown reason'}`); } else { setTimeout(poll, 2500); // still processing } }, onerror() { btn.innerHTML = origHTML; btn.disabled = false; showModal('Polling Error', 'Failed while checking prediction status.'); } }); } setTimeout(poll, 2500); } // ── Button factory ──────────────────────────────────────────────────────── function makeBtn(label, text, extraClass = '') { const btn = document.createElement('button'); btn.className = `tldr-btn${extraClass ? ' ' + extraClass : ''}`; btn.innerHTML = '📋 TL;DR'; btn.title = 'Summarize with DeepSeek via Replicate'; btn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); summarize(text, btn, label); }); return btn; } // ── Facebook ────────────────────────────────────────────────────────────── const isFacebook = location.hostname.includes('facebook.com'); function extractFBText(article) { // Ordered list of selectors from most specific to most generic const selectors = [ '[data-ad-preview="message"]', '[data-testid="post_message"]', '[class*="userContent"]', 'div[dir="auto"]', 'span[dir="auto"]' ]; for (const sel of selectors) { const nodes = article.querySelectorAll(sel); const combined = [...nodes] .map(n => n.innerText.trim()) .filter(t => t.length > 25) .join('\n'); if (combined.length > 30) return combined; } return article.innerText.trim(); } function injectFBBtn(article) { if (article.dataset.tldrDone) return; // Skip comments — they are articles nested inside another article if (article.parentElement && article.parentElement.closest('[role="article"]')) return; article.dataset.tldrDone = '1'; const text = extractFBText(article); if (!text || text.length < 40) return; const btn = makeBtn('Facebook Post', text); // Try to place the button near the reaction/action bar const actionRow = article.querySelector( '[aria-label*="React"], [aria-label*="Comment"], [aria-label*="Share"], ' + '[data-testid*="UFI"], div[role="toolbar"]' ); if (actionRow) { const parent = actionRow.closest('div') || actionRow.parentNode; parent.insertAdjacentElement('afterend', btn); } else { article.appendChild(btn); } } // Debounced scan — runs at most once per 600ms to avoid hammering the DOM let scanTimer = null; function scanFB() { clearTimeout(scanTimer); scanTimer = setTimeout(() => { document.querySelectorAll('div[role="article"]').forEach(injectFBBtn); }, 600); } // ── General articles / pages ────────────────────────────────────────────── function extractPageText() { const candidates = [ 'article', '[role="article"]', 'main article', '.article-body', '.article-content', '.post-body', '.post-content', '.entry-content', '.story-body', '#article-body', '#main-content', 'main' ]; for (const sel of candidates) { const el = document.querySelector(sel); if (el && el.innerText.trim().length > 150) { return el.innerText.trim(); } } return document.body.innerText.trim(); } function injectPageBtn() { if (document.querySelector('.tldr-btn.article-btn')) return; const text = extractPageText(); if (!text || text.length < 100) return; const label = (document.title || location.hostname).slice(0, 60); const btn = makeBtn(label, text, 'article-btn'); btn.innerHTML = '📋 TL;DR this page'; document.body.appendChild(btn); } // ── Bootstrap ───────────────────────────────────────────────────────────── if (isFacebook) { scanFB(); // Re-scan when FB dynamically injects new posts new MutationObserver(scanFB).observe(document.body, { childList: true, subtree: true }); } else { injectPageBtn(); // Some SPAs replace content after load window.addEventListener('load', injectPageBtn); } })();