// ==UserScript== // @name Save Page // @name:en Save Page // @name:zh-CN 保存页面 // @description Save page as single HTML file. // @description:en Save page as single HTML file. // @description:zh-CN 将页面保存为单个 HTML 文件。 // @namespace https://greasyfork.org/users/197529 // @version 0.1.7 // @author kkocdko // @license Unlicense // @match *://*/* // @grant GM_xmlhttpRequest // @noframes // @downloadURL https://update.greasyfork.icu/scripts/430398/Save%20Page.user.js // @updateURL https://update.greasyfork.icu/scripts/430398/Save%20Page.meta.js // ==/UserScript== "use strict"; const { addFloatButton, fetchex } = { addFloatButton(text, onclick) /* 20220509-1936 */ { if (!document.addFloatButton) { const host = document.body.appendChild(document.createElement("div")); const root = host.attachShadow({ mode: "open" }); root.innerHTML = ``; document.addFloatButton = (text, onclick) => { const el = document.createElement("label"); el.textContent = text; el.addEventListener("click", onclick); return root.appendChild(el); }; } return document.addFloatButton(text, onclick); }, fetchex(url, type) /* 20220509-1838 */ { // @grant GM_xmlhttpRequest if (self.GM_xmlhttpRequest) return new Promise((resolve, onerror) => { const onload = (e) => resolve(e.response); GM_xmlhttpRequest({ url, responseType: type, onload, onerror }); }); else return fetch(url).then((v) => v[type]()); }, }; // TODO: Content Security Policy. Example: https://github.com/kkocdko/kblog addFloatButton("Remove images", async function () { const placeholder = `data:image/svg+xml,`; document.querySelectorAll("img").forEach((el) => { el.src = placeholder; }); this.textContent = "Images removed"; this.style.background = "#4caf50"; }); addFloatButton("Save page", async function () { console.time("save page"); this.style.background = "#ff9800"; const interval = setInterval((o) => { const suffix = ".".padStart((++o.i % 3) + 1, " ").padEnd(3, " "); this.innerHTML = "Saving " + suffix.replace(/\s/g, " "); }, ...[333, { i: 0 }]); // 茴回囘囬 const /** @type {Document} */ dom = document.cloneNode(true); const removeList = `script, style, source, title, link`; dom.querySelectorAll(removeList).forEach((el) => el.remove()); const qsam = (s, f) => [...document.querySelectorAll(s)].map(f); const imgs = dom.querySelectorAll("img"); const imgTasks = qsam("img", async (el, i) => { const reader = new FileReader(); reader.readAsDataURL(await fetchex(el.currentSrc, "blob")); await new Promise((r) => (reader.onload = reader.onerror = r)); imgs[i].src = reader.result; imgs[i].srcset = ""; }); const css = []; // Keep order const cssTasks = qsam("style, link[rel=stylesheet]", async (el, i) => { if (el.tagName === "STYLE") css[i] = el.textContent; else css[i] = await fetchex(el.href, "text"); }); await Promise.allSettled([...imgTasks, ...cssTasks]); // [TODO:Limitation] `url()` and `image-set()` in css will not be save // Avoid the long-loading issue const cssStr = css .filter((v) => !v.slice(0, 128).includes(" { // "a:not([href^='http']):not([href^='//'])" el.setAttribute("href", el.href); // make links absolute }); // [TODO:Limitation] breaked some no-doctype / xhtml / html4 pages const result = "" + dom.documentElement.outerHTML; console.timeEnd("save page"); clearInterval(interval); const link = document.createElement("a"); // Using `dom` will cause failure link.download = `${document.title}_${Date.now()}.html`; link.href = "data:text/html," + encodeURIComponent(result); link.click(); this.textContent = "Page saved"; this.style.background = "#4caf50"; });