415 lines
16 KiB
JavaScript
415 lines
16 KiB
JavaScript
// ==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, '&')
|
|
.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 = '<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 systemPrompt = 'You are a concise summarizer. Write a 2-4 sentence TL;DR paragraph. Always mention who or what the content is about, the key events or arguments, and why it matters. Preserve names, dates, and context. Do not use bullet points. Do not add preamble like "This article is about" — just start summarizing.';
|
|
const prompt = `<|system|>${systemPrompt}<|user|>Summarize this:\n\n${text}<|assistant|>`;
|
|
|
|
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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
function scanFB() {
|
|
document.querySelectorAll('div[role="article"]').forEach(injectFBBtn);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
})();
|