保存页面

将页面保存为单个 HTML 文件。

目前为 2022-03-22 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Save Page
  3. // @name:zh-CN 保存页面
  4. // @description Save page as single HTML file.
  5. // @description:zh-CN 将页面保存为单个 HTML 文件。
  6. // @namespace https://greasyfork.org/users/197529
  7. // @version 0.1.2
  8. // @author kkocdko
  9. // @license Unlicense
  10. // @match *://*/*
  11. // @grant GM_xmlhttpRequest
  12. // @noframes
  13. // ==/UserScript==
  14. "use strict";
  15.  
  16. const { addFloatButton, fetchex } = {
  17. addFloatButton(text, onclick) /* 20220322-1526 */ {
  18. if (!document.addFloatButton) {
  19. const host = document.body.appendChild(document.createElement("div"));
  20. const root = host.attachShadow({ mode: "open" });
  21. root.innerHTML = `<style>:host{position:fixed;top:4px;left:4px;z-index:2147483647;height:0}#i{display:none}*{float:left;padding:1em;margin:4px;line-height:0;color:#fff;user-select:none;background:#28e;border-radius:8px;box-shadow:0 0 4px #aaa;transition:.3s}[for]~:active{background:#4af;transition:0s}:checked~*{opacity:.3;transform:translateY(-3em)}:checked+*{transform:translateY(3em)}</style><input id=i type=checkbox><label for=i>`;
  22. document.addFloatButton = (text, onclick) => {
  23. const el = document.createElement("label");
  24. el.textContent = text;
  25. el.addEventListener("click", onclick);
  26. return root.appendChild(el);
  27. };
  28. }
  29. return document.addFloatButton(text, onclick);
  30. },
  31. async fetchex(url, type) /* 20210904-1148 */ {
  32. // @grant GM_xmlhttpRequest
  33. if (self.GM_xmlhttpRequest)
  34. return new Promise((resolve, reject) => {
  35. GM_xmlhttpRequest({
  36. url,
  37. responseType: type,
  38. onload: (e) => resolve(e.response),
  39. onerror: reject,
  40. });
  41. });
  42. else return (await fetch(url))[type]();
  43. },
  44. };
  45.  
  46. // TODO: Content Security Policy. Example: https://github.com/kkocdko/kblog
  47.  
  48. addFloatButton("Save page", async function () {
  49. console.time("save page");
  50. this.style.background = "#ff9800";
  51. const interval = setInterval((o) => {
  52. const suffix = ".".padStart((++o.i % 3) + 1, " ").padEnd(3, " ");
  53. this.innerHTML = "Saving " + suffix.replace(/\s/g, "&nbsp;");
  54. }, ...[333, { i: 0 }]); // 茴回囘囬
  55.  
  56. const /** @type {Document} */ dom = document.cloneNode(true);
  57.  
  58. const removeList = `script, style, source, title, link[rel=stylesheet], link[rel=alternate], link[rel=search], link[rel*=pre], link[rel*=icon]`;
  59. dom.querySelectorAll(removeList).forEach((el) => el.remove());
  60.  
  61. const qsam = (s, f) => [...document.querySelectorAll(s)].map(f);
  62.  
  63. const imgs = dom.querySelectorAll("img");
  64. const imgTasks = qsam("img", async (el, i) => {
  65. const reader = new FileReader();
  66. reader.readAsDataURL(await fetchex(el.currentSrc, "blob"));
  67. await new Promise((r) => (reader.onload = reader.onerror = r));
  68. imgs[i].src = reader.result;
  69. imgs[i].srcset = "";
  70. });
  71.  
  72. const css = []; // Keep order
  73. const cssTasks = qsam("style, link[rel=stylesheet]", async (el, i) => {
  74. if (el.tagName === "STYLE") css[i] = el.textContent;
  75. else css[i] = await fetchex(el.href, "text");
  76. });
  77.  
  78. await Promise.allSettled([...imgTasks, ...cssTasks]);
  79.  
  80. // [TODO:Limitation] `url()` and `image-set()` in css will not be save
  81. // Avoid the long-loading issue
  82. const cssStr = css.join("\n").replace(/(url|image-set)(.+?)/g, "url()");
  83. dom.head.appendChild(dom.createElement("style")).textContent = cssStr;
  84.  
  85. // [TODO:Limitation] breaked some no-doctype / xhtml / html4 pages
  86. const result = "<!DOCTYPE html>" + dom.documentElement.outerHTML;
  87.  
  88. const link = document.createElement("a"); // Using `dom` will cause failure
  89. link.download = `${document.title}_${Date.now()}.html`;
  90. link.href = "data:text/html," + encodeURIComponent(result);
  91. link.click();
  92.  
  93. console.timeEnd("save page");
  94.  
  95. clearInterval(interval);
  96. this.textContent = "Page saved";
  97. this.style.background = "#4caf50";
  98. });