Luogu.me article2PDF

· · 科技·工程

感谢 CuteMurasame 的这篇文章为本文提供了灵感!

由于 Article2PDF「不支持从 luogu.me 打印成 PDF」,于是 DeepSeek-V3.2(和我)花了一个下午时间写了这段针对保存站文章的脚本。

食用方法与 Article2PDF 类似,具体的可以去看 Article2PDF 的介绍文章。需要注意的是该插件没有独立的「设置」功能,设置面板将在点击「打印为 PDF」时弹出。

:::info[Source Code] 基本由 DeepSeek 实现。

// ==UserScript==
// @name         Luogu.me Article2PDF
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  将 luogu.me 专栏快速打印为 PDF。
// @author       Gemini 3.1 Pro & Murasame & DeepSeek-V3.2 & ShootingStar
// @match        *://www.luogu.me/article/*
// @require      https://fastly.jsdelivr.net/npm/sweetalert2@11
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    if (typeof window.Swal === 'undefined') {
        const swalScript = document.createElement('script');
        swalScript.src = 'https://fastly.jsdelivr.net/npm/sweetalert2@11';
        document.head.appendChild(swalScript);
    }

    const DEFAULT_CONFIG = {
        mainFont: 'Lato, "Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei", sans-serif',
        codeFont: '"Fira Code", Consolas, Monaco, monospace',
        showCodeBlockBorder: true,
        showLineNumbers: true
    };

    function getConfig() {
        try {
            const saved = localStorage.getItem('luogu_me_pdf_config');
            return saved ? { ...DEFAULT_CONFIG, ...JSON.parse(saved) } : DEFAULT_CONFIG;
        } catch (e) {
            return DEFAULT_CONFIG;
        }
    }

    function saveConfig(cfg) {
        localStorage.setItem('luogu_me_pdf_config', JSON.stringify(cfg));
    }

    // 创建设置弹窗(返回 Promise)
    function showSettingsModal() {
        return new Promise((resolve) => {
            // 如果已经存在弹窗,先移除
            const existingModal = document.getElementById('luogu-me-pdf-modal');
            if (existingModal) {
                existingModal.remove();
            }

            const overlay = document.createElement('div');
            overlay.id = 'luogu-me-pdf-modal';
            overlay.style.position = 'fixed';
            overlay.style.top = '0';
            overlay.style.left = '0';
            overlay.style.width = '100vw';
            overlay.style.height = '100vh';
            overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
            overlay.style.zIndex = '999999';
            overlay.style.display = 'flex';
            overlay.style.justifyContent = 'center';
            overlay.style.alignItems = 'center';

            const modal = document.createElement('div');
            modal.style.backgroundColor = '#fff';
            modal.style.padding = '24px 32px';
            modal.style.borderRadius = '8px';
            modal.style.width = '400px';
            modal.style.boxShadow = '0 10px 25px rgba(0,0,0,0.2)';
            modal.style.fontFamily = 'sans-serif';

            const cfg = getConfig();

            modal.innerHTML = `
                <h3 style="margin-top:0; margin-bottom: 20px; font-size: 18px; color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px;">PDF 打印设置</h3>
                <div style="margin-bottom: 15px;">
                    <label style="display:block; font-size: 14px; margin-bottom: 5px; color: #555;">正文字体(留空使用默认):</label>
                    <input id="cfg-mainFont" type="text" value="${cfg.mainFont.replace(/"/g, '&quot;')}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;" />
                </div>
                <div style="margin-bottom: 15px;">
                    <label style="display:block; font-size: 14px; margin-bottom: 5px; color: #555;">代码块字体:</label>
                    <input id="cfg-codeFont" type="text" value="${cfg.codeFont.replace(/"/g, '&quot;')}" style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;" />
                </div>
                <div style="margin-bottom: 15px; display: flex; align-items: center;">
                    <input id="cfg-border" type="checkbox" ${cfg.showCodeBlockBorder ? 'checked' : ''} style="margin-right: 8px; width: 16px; height: 16px;" />
                    <label for="cfg-border" style="font-size: 14px; color: #333; cursor: pointer;">为代码块添加边框</label>
                </div>
                <div style="margin-bottom: 25px; display: flex; align-items: center;">
                    <input id="cfg-linenum" type="checkbox" ${cfg.showLineNumbers ? 'checked' : ''} style="margin-right: 8px; width: 16px; height: 16px;" />
                    <label for="cfg-linenum" style="font-size: 14px; color: #333; cursor: pointer;">显示代码块行号</label>
                </div>
                <div style="display: flex; justify-content: flex-end; gap: 10px;">
                    <button id="cfg-cancel" style="padding: 8px 16px; border: none; background: #e0e0e0; color: #333; border-radius: 4px; cursor: pointer;">取消</button>
                    <button id="cfg-print" style="padding: 8px 16px; border: none; background: #3498db; color: #fff; border-radius: 4px; cursor: pointer;">打印 PDF</button>
                </div>
            `;

            overlay.appendChild(modal);
            document.body.appendChild(overlay);

            document.getElementById('cfg-cancel').onclick = () => {
                overlay.remove();
                resolve(null); // 用户取消
            };

            document.getElementById('cfg-print').onclick = () => {
                const newCfg = {
                    mainFont: document.getElementById('cfg-mainFont').value,
                    codeFont: document.getElementById('cfg-codeFont').value,
                    showCodeBlockBorder: document.getElementById('cfg-border').checked,
                    showLineNumbers: document.getElementById('cfg-linenum').checked
                };
                saveConfig(newCfg);
                overlay.remove();
                resolve(newCfg); // 用户确认打印
            };
        });
    }

    console.log('Luogu.me Article2PDF 已加载');

    // 获取文章内容
    function getArticleContent() {
        return document.getElementById('render-content');
    }

    // 获取按钮容器 - 精确定位到包含操作按钮的那一行
    function getButtonContainer() {
        // 方法1: 直接查找包含特定按钮的容器
        const possibleContainers = [
            document.querySelector('a[href*="/article/"]')?.closest('.sixteen.wide.column'),
            document.querySelector('a[onclick="copyMarkdown()"]')?.closest('.sixteen.wide.column'),
            document.querySelector('#save-btn')?.closest('.sixteen.wide.column'),
            document.querySelector('#request-deletion-btn')?.closest('.sixteen.wide.column')
        ];

        // 返回第一个找到的容器
        for (const container of possibleContainers) {
            if (container) return container;
        }

        // 方法2: 如果上面的方法没找到,遍历所有 sixteen wide column
        const columns = document.querySelectorAll('.sixteen.wide.column');
        for (const column of columns) {
            // 检查是否包含任何按钮
            const buttons = column.querySelectorAll('.ui.button');
            if (buttons.length >= 2) { // 至少有2个按钮才认为是操作栏
                return column;
            }
        }

        return null;
    }

    // 展开自定义折叠块
    function expandCustomBlocks(content) {
        const blockStates = [];

        content.querySelectorAll('.md-block').forEach(block => {
            const body = block.querySelector('.md-block-body');
            const title = block.querySelector('.md-block-title');
            const toggleBtn = title ? title.querySelector('.toggle-btn') : null;

            if (body) {
                blockStates.push({
                    block: block,
                    body: body,
                    originalDisplay: body.style.display,
                    toggleBtn: toggleBtn
                });

                body.style.display = 'block';

                if (toggleBtn) {
                    toggleBtn.style.transform = 'rotate(90deg)';
                }
            }
        });

        return blockStates;
    }

    // 处理代码块
    function processCodeBlocks(content, CONFIG) {
        const preStates = [];

        content.querySelectorAll('.code-container').forEach(container => {
            const pre = container.querySelector('pre');
            const code = pre ? pre.querySelector('code') : null;

            if (!code) return;

            // 保存原始状态
            preStates.push({
                container: container,
                code: code,
                originalHTML: code.innerHTML
            });

            // 处理行号 - 只有当用户选择不显示行号时,才移除行号
            if (!CONFIG.showLineNumbers) {
                const lines = code.querySelectorAll('span.line');
                if (lines.length > 0) {
                    let codeText = '';
                    lines.forEach(line => {
                        codeText += line.innerHTML + '\n';
                    });
                    codeText = codeText.replace(/\n$/, '');
                    code.innerHTML = codeText;
                }
            }
            // 如果显示行号,什么都不做,保留原有行号
        });

        return preStates;
    }

    // 执行打印
    async function performPrint(content, CONFIG) {
        const hiddenElements = [];
        const blockStates = [];
        const pageBreakStates = [];
        const modifiedTables = [];

        // 展开自定义折叠块
        const states = expandCustomBlocks(content);
        blockStates.push(...states);

        // 处理代码块
        const preStates = processCodeBlocks(content, CONFIG);

        // 处理分页符
        let firstPageBreak = null;
        content.querySelectorAll('p').forEach(p => {
            if (p.textContent.trim() === '===pagebreak===') {
                if (!firstPageBreak) firstPageBreak = p;
                pageBreakStates.push({ el: p, cssText: p.style.cssText });
                p.style.pageBreakAfter = 'always';
                p.style.breakAfter = 'always';
                p.style.color = 'transparent';
                p.style.height = '0';
                p.style.margin = '0';
                p.style.padding = '0';
                p.style.overflow = 'hidden';
            }
        });

        // 处理表格
        content.querySelectorAll('table').forEach(table => {
            let isBeforeFirstPageBreak = true;
            if (firstPageBreak) {
                const position = table.compareDocumentPosition(firstPageBreak);
                isBeforeFirstPageBreak = !!(position & Node.DOCUMENT_POSITION_FOLLOWING);
            }
            if (isBeforeFirstPageBreak) {
                modifiedTables.push(table);
                table.style.width = '100%';
                table.style.maxWidth = '100%';
            }
        });

        // 隐藏页面其他元素,只保留内容
        let curr = content;
        while (curr && curr !== document.body) {
            const siblings = curr.parentNode ? curr.parentNode.children : [];
            for (let i = 0; i < siblings.length; i++) {
                const sibling = siblings[i];
                if (sibling !== curr &&
                    sibling.tagName !== 'SCRIPT' &&
                    sibling.tagName !== 'STYLE' &&
                    sibling.id !== 'luogu-me-pdf-modal') {

                    hiddenElements.push({
                        el: sibling,
                        display: sibling.style.display,
                        visibility: sibling.style.visibility
                    });

                    sibling.style.display = 'none';
                    sibling.style.visibility = 'hidden';
                }
            }
            curr = curr.parentNode;
        }

        // 添加打印样式
        const printStyle = document.createElement('style');
        printStyle.innerHTML = `
            @media print {
                * {
                    -webkit-print-color-adjust: exact !important;
                    print-color-adjust: exact !important;
                }

                @page {
                    margin: 1.5cm;
                }

                body {
                    background: white !important;
                    margin: 0 !important;
                    padding: 0 !important;
                    width: 100% !important;
                }

                /* 隐藏打印按钮本身 */
                #luogu-me-pdf-print-btn {
                    display: none !important;
                }

                /* 移除最外层容器的限制 */
                .card.shadow.outline.md-container {
                    border: none !important;
                    box-shadow: none !important;
                    outline: none !important;
                    margin: 0 !important;
                    padding: 0 !important;
                    width: 100% !important;
                    max-width: 100% !important;
                }

                /* 让内容容器占满 */
                #render-content {
                    display: block !important;
                    width: 100% !important;
                    max-width: 100% !important;
                    margin: 0 !important;
                    padding: 0 !important;
                    border: none !important;
                    background: white !important;
                }

                /* 确保所有父容器都占满宽度 */
                .ui.main.container,
                .ui.grid,
                .sixteen.wide.column,
                .card.shadow.outline {
                    width: 100% !important;
                    max-width: 100% !important;
                    margin: 0 !important;
                    padding: 0 !important;
                    border: none !important;
                }

                pre, blockquote, tr, img {
                    page-break-inside: avoid;
                }

                /* 展开所有折叠内容 */
                .md-block .md-block-body {
                    display: block !important;
                }

                /* 表格样式优化 */
                table {
                    width: 100% !important;
                    max-width: 100% !important;
                    border-collapse: collapse !important;
                }

                /* 字体设置 */
                ${CONFIG.mainFont ? `#render-content { font-family: ${CONFIG.mainFont} !important; }` : ''}
                ${CONFIG.codeFont ? `.code-container, pre, code, .shiki { font-family: ${CONFIG.codeFont} !important; }` : ''}

                /* 代码块边框 */
                ${CONFIG.showCodeBlockBorder ? `
                .code-container {
                    border: 1px solid #d1d5db !important;
                    border-radius: 6px !important;
                }
                ` : ''}

                /* 代码块样式 */
                .code-container {
                    background: #ffffff !important;
                    margin: 1em 0 !important;
                }

                pre {
                    margin: 0 !important;
                    padding: 0 !important;
                    background: #ffffff !important;
                    white-space: pre-wrap !important;
                    word-wrap: break-word !important;
                }

                /* 如果不需要行号 */
                ${!CONFIG.showLineNumbers ? `
                span.line {
                    display: block;
                }
                ` : ''}

                /* 隐藏所有干扰元素 */
                .ui.left.hover.vertical.sidebar,
                .ui.vertical.footer.segment,
                .ui.divider,
                .meta:not(.user),
                [href="/user/login"],
                [href="/user/logout"] {
                    display: none !important;
                }
            }
        `;
        document.head.appendChild(printStyle);

        // 执行打印
        window.print();

        // 恢复所有修改
        hiddenElements.forEach(item => {
            item.el.style.display = item.display;
            item.el.style.visibility = item.visibility;
        });

        // 恢复折叠块
        blockStates.forEach(item => {
            if (item.body) {
                item.body.style.display = item.originalDisplay;
            }
            if (item.toggleBtn) {
                item.toggleBtn.style.transform = '';
            }
        });

        pageBreakStates.forEach(item => {
            item.el.style.cssText = item.cssText;
        });

        modifiedTables.forEach(table => {
            table.style.width = '';
            table.style.maxWidth = '';
        });

        // 恢复代码块
        preStates.forEach(item => {
            item.code.innerHTML = item.originalHTML;
        });

        document.head.removeChild(printStyle);
    }

    function injectPrintButton() {
        // 检查是否已经添加过按钮
        if (document.getElementById('luogu-me-pdf-print-btn')) return;

        const content = getArticleContent();
        const buttonContainer = getButtonContainer();

        if (!content || !buttonContainer) {
            console.log('未找到文章内容或按钮容器,稍后重试...', { content, buttonContainer });
            return;
        }

        // 创建打印按钮(蓝色样式)
        const printBtn = document.createElement('a');
        printBtn.id = 'luogu-me-pdf-print-btn';
        printBtn.href = 'javascript:void(0)';
        printBtn.className = 'ui blue button';  // 使用 blue 类获取蓝色样式
        printBtn.style.marginLeft = '5px';
        printBtn.innerHTML = '<i class="ui icon print"></i> 打印为 PDF';

        // 添加到按钮容器
        buttonContainer.appendChild(printBtn);

        // 点击事件:先弹出设置,再打印
        printBtn.addEventListener('click', async () => {
            const config = await showSettingsModal();
            if (config) {
                // 用户点击了打印,执行打印
                await performPrint(content, config);
            }
            // 用户点击取消,什么都不做
        });
    }

    // 初始化
    setTimeout(injectPrintButton, 1000);
    setTimeout(injectPrintButton, 3000);
    setTimeout(injectPrintButton, 5000);

    const observer = new MutationObserver(() => injectPrintButton());
    observer.observe(document.body, { childList: true, subtree: true });
})();

:::

演示专栏生成的 PDF: https://docs.qq.com/pdf/DWXR3amx1RFBxemdE

(由于保存站不支持 cute-table,所以在演示 PDF 中有一部分 :: cute-table 没有渲染,不是插件本身的问题)