Silent Article Printer (Readability intercept)

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

当前为 2025-09-23 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
    }

    // 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();

})();