// ==UserScript==
// @name Silent Article Printer (Readability intercept)
// @namespace https://example.com/iamnobody
// @version 2.0.0
// @description Silently replace page content with the parsed main article when user invokes Print. Keeps selectable text, constrains layout to A4 with 0 visual margins where possible, scales wide media to fit A4. Opens native print dialog for user confirmation. Works on most websites; excludes common search engines. MIT License. Author: iamnobody
// @author iamnobody
// @license MIT
// @match *://*/*
// @exclude *://www.google.*/*
// @exclude *://www.bing.com/*
// @exclude *://search.yahoo.com/*
// @exclude *://duckduckgo.com/*
// @exclude *://www.baidu.com/*
// @grant none
// @run-at document-end
// @require https://cdn.jsdelivr.net/npm/@mozilla/[email protected]/Readability.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js
// ==/UserScript==
(function () {
'use strict';
/**
Behavior summary (per your choices):
- Selectable text approach: when user triggers Print (Ctrl/Cmd+P or print menu), we replace page body with the sanitized article content (Readability + fallback) and let the browser's native print dialog open.
- After the print action completes (or is canceled), we restore the original page so browsing continues normally.
- If auto-detection fails or yields too little content, a one-time lightweight overlay asks you to click the correct article area. No persistent floating buttons.
- Scale-to-fit behavior for wide tables/images (we constrain media to page width rather than switching to A3).
Limitations (transparent):
- Browser-controlled print headers/footers (URL, date, page numbers) cannot be programmatically disabled in all browsers. Users may need to disable them in the print dialog for a headerless PDF.
- Script cannot set the filename when using the native print dialog.
**/
// --- Configuration ---
const MIN_ARTICLE_LENGTH = 200; // chars to consider "valid" article
const OVERLAY_ID = 'iamnobody-select-overlay';
const RESTORE_TIMEOUT_MS = 2000; // safety timeout to restore page if afterprint doesn't fire
// Save original state
let originalHTML = null;
let originalTitle = document.title;
let restoring = false;
let overlayActive = false;
// Utility: sanitize HTML
function sanitize(html) {
try {
return DOMPurify.sanitize(html, { ALLOWED_TAGS: false });
} catch (e) {
return html;
}
}
// Extract article via Readability with fallbacks
function detectArticle() {
try {
const docClone = document.cloneNode(true);
const parsed = new Readability(docClone).parse();
if (parsed && parsed.content && parsed.textContent && parsed.textContent.length >= MIN_ARTICLE_LENGTH) {
return { title: parsed.title || document.title, content: parsed.content };
}
} catch (e) {
console.warn('Readability parse error', e);
}
// fallback selectors
const selectors = ['article', 'main', '[role=main]', '.post', '.article', '#article', '.entry-content', '.content', '.post-content'];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && (el.innerText || '').trim().length >= MIN_ARTICLE_LENGTH) {
return { title: document.title, content: el.innerHTML };
}
}
// fallback: largest text block
const all = Array.from(document.body.querySelectorAll('p, div, section'));
let best = null; let bestLen = 0;
for (const el of all) {
const len = (el.innerText || '').length;
if (len > bestLen) { bestLen = len; best = el; }
}
if (best && bestLen >= MIN_ARTICLE_LENGTH) {
return { title: document.title, content: best.innerHTML };
}
return null;
}
// Build article wrapper HTML (keeps selectable text)
function buildArticleDocument(title, contentHtml) {
// CSS attempts to make print friendly: A4-like width and remove extra margins visually.
// Note: browser print margins may still apply; user can choose "Margins: None" in print dialog when available.
const css = `
html, body { height:100%; margin:0; padding:0; background: #fff; }
@media screen, print {
:root { --page-width:794px; } /* A4 width approx at 96dpi */
body { font-family: Georgia, 'Times New Roman', serif; color:#111; }
.iamnobody-reader { box-sizing:border-box; width:var(--page-width); margin:0 auto; padding:12px; }
h1.iamnobody-title { font-size:20px; margin:6px 0 10px 0; }
.iamnobody-meta { font-size:12px; color:#666; margin-bottom:10px; }
img { max-width:100%; height:auto; display:block; margin:8px 0; }
table { max-width:100%; width:auto; border-collapse:collapse; display:block; overflow:auto; margin:8px 0; }
table th, table td { border:1px solid #ccc; padding:6px 8px; }
pre { white-space:pre-wrap; word-break:break-word; }
/* Minimize default print margins visually */
@page { size: A4; margin: 0; }
}
`;
const safeContent = sanitize(contentHtml);
const meta = `Saved from ${location.hostname} — ${new Date().toLocaleString()}`;
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>${css}</style></head><body><article class="iamnobody-reader"><h1 class="iamnobody-title">${escapeHtml(title)}</h1><div class="iamnobody-meta">${escapeHtml(meta)}</div><div class="iamnobody-content">${safeContent}</div></article></body></html>`;
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
}
// Show small overlay to let user click the correct article area
function showSelectionOverlay(onSelect) {
if (overlayActive) return;
overlayActive = true;
const ov = document.createElement('div');
ov.id = OVERLAY_ID;
Object.assign(ov.style, {
position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.08)', zIndex: 2147483646, cursor: 'crosshair'
});
const hint = document.createElement('div');
hint.textContent = 'Click the main article area to select it for printing. Press Esc to cancel.';
Object.assign(hint.style, {
position: 'fixed', top: '12px', left: '50%', transform: 'translateX(-50%)', background:'#fff', padding:'8px 12px', borderRadius:'8px', boxShadow:'0 6px 18px rgba(0,0,0,0.12)', zIndex:2147483647
});
document.body.appendChild(ov);
document.body.appendChild(hint);
function clickHandler(e) {
e.preventDefault(); e.stopPropagation();
// Walk up to find a meaningful container (stop at body)
let el = e.target;
while (el && el !== document.body) {
const textLen = (el.innerText || '').trim().length;
if (textLen >= MIN_ARTICLE_LENGTH) break;
el = el.parentElement;
}
cleanup();
if (el && el !== document.body) onSelect(el);
else onSelect(null);
}
function keyHandler(e) {
if (e.key === 'Escape') { cleanup(); onSelect(null); }
}
function cleanup() {
overlayActive = false;
ov.remove(); hint.remove();
document.removeEventListener('click', clickHandler, true);
document.removeEventListener('keydown', keyHandler, true);
}
document.addEventListener('click', clickHandler, true);
document.addEventListener('keydown', keyHandler, true);
}
// Swap document body to article HTML and optionally change title
function replaceBodyWithArticle(article) {
try {
if (!article || !article.content) throw new Error('No article content');
originalHTML = document.documentElement.outerHTML;
originalTitle = document.title || originalTitle;
const articleDoc = buildArticleDocument(article.title || originalTitle, article.content);
// Use document.open/write to replace entire page so print uses the new content
document.open();
document.write(articleDoc);
document.close();
// Small delay to let layout settle
} catch (e) {
console.error('replaceBodyWithArticle failed', e);
throw e;
}
}
// Restore original page
function restoreOriginal() {
if (restoring) return;
restoring = true;
try {
if (originalHTML) {
// Replace document with original HTML. Use location.reload as safe fallback if writing fails.
try {
document.open();
document.write(originalHTML);
document.close();
} catch (e) {
console.warn('Restore via document.write failed, reloading page instead', e);
location.reload();
}
document.title = originalTitle;
originalHTML = null;
}
} finally {
restoring = false;
}
}
// Handler when print is invoked
async function handleBeforePrint(ev) {
try {
// If we've already replaced and are printing, no-op
if (document.documentElement && document.documentElement.querySelector('.iamnobody-reader')) return;
// Try auto-detect
let article = detectArticle();
if (!article) {
// Ask user to click selection (fallback mode)
// We need to stop the print flow until selection is made. Some browsers call beforeprint synchronously.
// We'll attempt to pause by showing overlay and then programmatically calling print after selection.
// To avoid interfering with synchronous native print calls, we'll cancel here if overlay can't run and let print proceed normally.
try {
// Prevent further immediate printing by returning - let print dialog continue (best effort)
// Show selection overlay; when user selects, we programmatically open a new window with article and call print there.
showSelectionOverlay(function (el) {
if (!el) {
// user canceled selection — nothing to do
return;
}
// Build article from chosen element
const content = el.innerHTML;
const title = (el.querySelector('h1') || document.querySelector('title')).innerText || document.title;
const articleData = { title, content };
// Open a new window and write article then print
const w = window.open('', '_blank');
if (!w) { alert('Popup blocked. Allow popups to open a print preview.'); return; }
w.document.open();
w.document.write(buildArticleDocument(articleData.title, articleData.content));
w.document.close();
// Defer printing slightly to allow images to load
setTimeout(()=>{ try { w.focus(); w.print(); } catch(e){ console.error(e); } }, 800);
});
} catch (overlayErr) {
console.warn('Selection overlay failed', overlayErr);
}
// Let original print continue (we couldn't reliably pause it here). Returning.
return;
}
// We have an article. Replace body (in-place) so native print dialog prints only article.
replaceBodyWithArticle(article);
// Safety: restore after a timeout if afterprint doesn't fire
setTimeout(()=>{
restoreOriginal();
}, RESTORE_TIMEOUT_MS + 1500);
} catch (err) {
console.error('beforeprint handler error', err);
}
}
function handleAfterPrint(ev) {
try {
// restore original document
restoreOriginal();
} catch (e) {
console.error('afterprint handler error', e);
}
}
// Attach listeners
function attachPrintListeners() {
try {
window.addEventListener('beforeprint', handleBeforePrint);
window.addEventListener('afterprint', handleAfterPrint);
// Also intercept Ctrl/Cmd+P to try to handle cases where beforeprint is unreliable.
window.addEventListener('keydown', function (e) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const meta = isMac ? e.metaKey : e.ctrlKey;
if (meta && e.key.toLowerCase() === 'p') {
// Give browser default behavior but also try our handler (we cannot prevent default reliably in some browsers)
try { handleBeforePrint(); } catch (e) { console.error(e); }
// allow native dialog to open — our replacement may have already occurred
}
});
} catch (e) {
console.error('attachPrintListeners failed', e);
}
}
// Debugging logs? Enabled by user choice earlier. We'll keep minimal console logs but not noisy.
console.log('Silent Article Printer: initialized (selectable-text mode).');
// Initialize
attachPrintListeners();
})();