Greasy Fork

来自缓存

Greasy Fork is available in English.

网页完整渲染保存工具

自动滚动加载懒加载内容 | 可视化配置 | 进度条 | 可拖动悬浮按钮 + 快捷键 | 完整长截图

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         网页完整渲染保存工具
// @namespace    http://tampermonkey.net/
// @version      20260313
// @description  自动滚动加载懒加载内容 | 可视化配置 | 进度条 | 可拖动悬浮按钮 + 快捷键 | 完整长截图
// @author       lidg
// @match        *://*/*
// @grant        none
// @license      MIT
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/html2canvas.min.js
// ==/UserScript==

(function () {
    'use strict';
    let isSaving = false;
    const MAX_CANVAS_EDGE = 16384;
    const MAX_CANVAS_AREA = 268435456;
    const CAPTURE_EXCLUDE_ATTR = 'data-html2canvas-ignore';
    const LOG_PREFIX = '[page-capture]';
    const DEBUG_ENABLED = true;

    function logInfo(message, payload) {
        if (!DEBUG_ENABLED) {
            return;
        }
        if (typeof payload === 'undefined') {
            console.log(LOG_PREFIX, message);
            return;
        }
        console.log(LOG_PREFIX, message, payload);
    }

    function logWarn(message, payload) {
        if (!DEBUG_ENABLED) {
            return;
        }
        if (typeof payload === 'undefined') {
            console.warn(LOG_PREFIX, message);
            return;
        }
        console.warn(LOG_PREFIX, message, payload);
    }

    function logError(message, payload) {
        if (!DEBUG_ENABLED) {
            return;
        }
        if (typeof payload === 'undefined') {
            console.error(LOG_PREFIX, message);
            return;
        }
        console.error(LOG_PREFIX, message, payload);
    }

    // ========== 进度条 ==========
    function showProgress() {
        const box = document.createElement('div');
        box.setAttribute(CAPTURE_EXCLUDE_ATTR, 'true');
        box.style.cssText = `
            position:fixed; left:50%; top:50%; z-index:9999999;
            transform:translate(-50%,-50%); background:#fff;
            padding:20px; border-radius:12px; width:280px;
            box-shadow:0 0 20px rgba(0,0,0,0.3); text-align:center;
        `;
        box.innerHTML = `
            <div style="font-weight:bold;margin-bottom:10px;" id="p-text">准备开始...</div>
            <div style="width:100%;height:8px;background:#eee;border-radius:4px;overflow:hidden;">
                <div id="p-bar" style="height:100%;width:0%;background:#0077ed;transition:width 0.3s;"></div>
            </div>
        `;
        document.body.appendChild(box);
        const set = (t, p) => {
            const textElement = document.getElementById('p-text');
            const progressElement = document.getElementById('p-bar');
            if (textElement && progressElement) {
                textElement.innerText = t;
                progressElement.style.width = p + '%';
            }
        };
        const close = () => {
            if (box.parentNode) {
                box.parentNode.removeChild(box);
            }
        };
        return { set, close };
    }

    // ========== 可拖动悬浮按钮 ==========
    function createFloatButton() {
        const btn = document.createElement('div');
        btn.setAttribute(CAPTURE_EXCLUDE_ATTR, 'true');
        btn.innerText = '💾 保存网页';
        btn.style.cssText = `
            position:fixed; bottom:30px; right:20px; z-index:999999;
            background:#0077ed; color:#fff; padding:10px 14px;
            border-radius:8px; font-size:14px; cursor:move;
            user-select:none; box-shadow:0 3px 12px rgba(0,0,0,0.2);
        `;
        let isDrag = false;
        let hasMoved = false;
        let ox = 0;
        let oy = 0;
        btn.addEventListener('mousedown', e => {
            isDrag = true;
            hasMoved = false;
            ox = e.clientX - btn.getBoundingClientRect().left;
            oy = e.clientY - btn.getBoundingClientRect().top;
            btn.style.cursor = 'grabbing';
        });
        document.addEventListener('mousemove', e => {
            if (!isDrag) {
                return;
            }
            hasMoved = true;
            btn.style.left = e.clientX - ox + 'px';
            btn.style.top = e.clientY - oy + 'px';
            btn.style.bottom = 'auto';
            btn.style.right = 'auto';
        });
        document.addEventListener('mouseup', async () => {
            if (!isDrag) {
                return;
            }
            isDrag = false;
            btn.style.cursor = 'move';
            if (!hasMoved && !isSaving) {
                await startSave();
            }
        });
        document.body.appendChild(btn);
    }

    // ========== 快捷键 Ctrl+Shift+S ==========
    document.addEventListener('keydown', e => {
        if (e.ctrlKey && e.shiftKey && e.key === 'S') {
            e.preventDefault();
            if (!isSaving) {
                startSave();
            }
        }
    });

    // ========== 配置面板 ==========
    async function showConfigPanel() {
        return new Promise(resolve => {
            const panel = document.createElement('div');
            panel.setAttribute(CAPTURE_EXCLUDE_ATTR, 'true');
            panel.style.cssText = `
                position:fixed; top:50%; left:50%; z-index:999999;
                transform:translate(-50%,-50%); background:#fff;
                width:280px; padding:20px; border-radius:12px;
                box-shadow:0 0 20px rgba(0,0,0,0.3); box-sizing:border-box;
            `;
            panel.innerHTML = `
                <div id="x-close" style="position:absolute;top:12px;right:16px;font-size:18px;cursor:pointer;color:#666;">×</div>
                <h3 style="margin:0 0 14px;">保存设置</h3>
                <div style="margin-bottom:12px;">
                    <label style="font-weight:bold;">清晰度:</label>
                    <label style="display:block;margin:4px 0;"><input type="radio" name="scale" value="1"> 标准 1x</label>
                    <label style="display:block;margin:4px 0;"><input type="radio" name="scale" value="2"> 高清 2x</label>
                    <label style="display:block;margin:4px 0;"><input type="radio" name="scale" value="3"> 超高清 3x</label>
                    <label style="display:block;margin:4px 0;"><input type="radio" name="scale" value="4" checked> 极致 4x</label>
                </div>
                <div style="margin-bottom:18px;">
                    <label style="font-weight:bold;">格式:</label>
                    <label style="display:block;margin:4px 0;"><input type="radio" name="fmt" value="png" checked> PNG(高清)</label>
                    <label style="display:block;margin:4px 0;"><input type="radio" name="fmt" value="webp"> WebP(小体积)</label>
                </div>
                <button id="x-start" style="width:100%;padding:10px;background:#0077ed;color:#fff;border:none;border-radius:8px;cursor:pointer;">开始保存</button>
            `;
            document.body.appendChild(panel);
            panel.querySelector('#x-close').onclick = () => {
                document.body.removeChild(panel);
                resolve(null);
            };
            panel.querySelector('#x-start').onclick = () => {
                const scale = +document.querySelector('input[name="scale"]:checked').value;
                const format = document.querySelector('input[name="fmt"]:checked').value;
                document.body.removeChild(panel);
                resolve({ scale, format });
            };
        });
    }

    function getPageMetrics() {
        const doc = document.documentElement;
        const body = document.body || doc;
        return {
            width: Math.max(doc.clientWidth, doc.scrollWidth, doc.offsetWidth, body.scrollWidth, body.offsetWidth),
            height: Math.max(doc.clientHeight, doc.scrollHeight, doc.offsetHeight, body.scrollHeight, body.offsetHeight)
        };
    }

    // ========== ✅ 核心修复:完整滚动加载(能触发懒加载) ==========
    async function fullScrollAndLoad() {
        return new Promise(resolve => {
            let totalHeight = getPageMetrics().height;
            let current = 0;
            let step = window.innerHeight;
            let delay = 400;
            logInfo('start full scroll load', { totalHeight: totalHeight, step: step, delay: delay });

            const scrollNext = () => {
                current += step;
                if (current >= totalHeight) {
                    setTimeout(() => {
                        window.scrollTo(0, 0);
                        logInfo('full scroll load finished', { finalHeight: totalHeight });
                        setTimeout(resolve, 600);
                    }, 400);
                    return;
                }
                window.scrollTo(0, current);
                totalHeight = getPageMetrics().height;
                setTimeout(scrollNext, delay);
            };
            scrollNext();
        });
    }

    function getSafeScale(scale, width, height) {
        const edgeLimitedScale = Math.min(
            scale,
            MAX_CANVAS_EDGE / Math.max(width, 1),
            MAX_CANVAS_EDGE / Math.max(height, 1)
        );
        const areaLimitedScale = Math.sqrt(MAX_CANVAS_AREA / Math.max(width * height, 1));
        return Math.max(1, Math.min(edgeLimitedScale, areaLimitedScale));
    }

    async function renderSlice(y, sliceHeight, scale, width, fullHeight) {
        logInfo('render slice start', { y: y, sliceHeight: sliceHeight, scale: scale, width: width, fullHeight: fullHeight });
        return html2canvas(document.documentElement, {
            scale: scale,
            useCORS: true,
            allowTaint: false,
            logging: false,
            backgroundColor: '#ffffff',
            scrollX: 0,
            scrollY: 0,
            y: y,
            height: sliceHeight,
            windowWidth: width,
            windowHeight: fullHeight,
            ignoreElements: element => element.hasAttribute(CAPTURE_EXCLUDE_ATTR)
        });
    }

    function downloadCanvas(canvas, format, fileName) {
        const a = document.createElement('a');
        a.download = fileName;
        a.href = canvas.toDataURL(`image/${format}`);
        logInfo('download canvas', { fileName: fileName, width: canvas.width, height: canvas.height, format: format });
        a.click();
    }

    // ========== 保存长截图 ==========
    async function saveImage(scale, format, prog) {
        const metrics = getPageMetrics();
        const safeScale = getSafeScale(scale, metrics.width, metrics.height);
        const scaledWidth = Math.max(1, Math.round(metrics.width * safeScale));
        const maxSliceHeight = Math.max(1, Math.floor(MAX_CANVAS_AREA / scaledWidth));
        const cssSliceHeight = Math.max(800, Math.min(metrics.height, Math.floor(maxSliceHeight / safeScale), MAX_CANVAS_EDGE));
        const pageCount = Math.ceil(metrics.height / cssSliceHeight);
        const host = location.hostname;
        const time = new Date().toISOString().replace(/\D/g, '').slice(0, 14);
        logInfo('save image config', {
            requestedScale: scale,
            safeScale: safeScale,
            format: format,
            metrics: metrics,
            scaledWidth: scaledWidth,
            maxSliceHeight: maxSliceHeight,
            cssSliceHeight: cssSliceHeight,
            pageCount: pageCount
        });

        if (safeScale < scale) {
            logWarn('scale adjusted because page exceeds canvas limit', { requestedScale: scale, safeScale: safeScale });
            alert(`页面过长,清晰度已自动从 ${scale}x 调整为 ${safeScale.toFixed(2)}x,以避免浏览器画布超限。`);
        }

        for (let index = 0; index < pageCount; index++) {
            const y = index * cssSliceHeight;
            const sliceHeight = Math.min(cssSliceHeight, metrics.height - y);
            prog.set(`生成截图分片 ${index + 1}/${pageCount}...`, 80 + Math.floor(((index + 1) / pageCount) * 18));
            const canvas = await renderSlice(y, sliceHeight, safeScale, metrics.width, metrics.height);
            const suffix = pageCount > 1 ? `_part${index + 1}` : '';
            logInfo('render slice finished', { index: index + 1, pageCount: pageCount, canvasWidth: canvas.width, canvasHeight: canvas.height });
            downloadCanvas(canvas, format, `【完整网页】_${host}_${time}${suffix}.${format}`);
        }
    }

    // ========== 主流程 ==========
    async function startSave() {
        if (isSaving) return;
        isSaving = true;
        logInfo('start save', { url: location.href, title: document.title });
        const cfg = await showConfigPanel();
        if (!cfg) {
            logInfo('save cancelled by user');
            isSaving = false;
            return;
        }

        const prog = showProgress();
        try {
            prog.set('滚动加载全部内容...', 30);
            await fullScrollAndLoad();
            prog.set('生成完整长截图...', 80);
            await saveImage(cfg.scale, cfg.format, prog);
            prog.set('保存成功!', 100);
            logInfo('save success');
            setTimeout(() => {
                if (prog && prog.close) {
                    prog.close();
                }
            }, 1000);
        } catch (e) {
            prog.close();
            logError('save failed', {
                message: e && e.message,
                stack: e && e.stack,
                name: e && e.name
            });
            alert('❌ 保存失败');
        } finally {
            isSaving = false;
        }
    }

    // 确保在页面加载完成后才创建按钮
    window.addEventListener('load', () => {
        createFloatButton(); // 创建右下角的按钮
    });
})();