Greasy Fork is available in English.
自动滚动加载懒加载内容 | 可视化配置 | 进度条 | 可拖动悬浮按钮 + 快捷键 | 完整长截图
// ==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(); // 创建右下角的按钮 }); })();