Files
tldr.js/tldr-summarizer.user.js

435 lines
17 KiB
JavaScript
Raw Normal View History

// ==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 = `
<div class="tldr-modal">
<div class="tldr-modal-header">
<h3 class="tldr-modal-title">📋 TL;DR ${titleText}</h3>
<button class="tldr-modal-close" title="Close"></button>
</div>
<div class="tldr-modal-body">${escapeHtml(formattedBody)}</div>
</div>
`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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 = '<span class="tldr-spinner"></span> 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 <think>…</think> reasoning blocks
summary = summary.replace(/<think>[\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(/<think>[\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);
}
})();