保存页面

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

当前为 2021-08-05 提交的版本,查看 最新版本

  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.0
  8. // @author kkocdko
  9. // @license Unlicense
  10. // @match *://*/*
  11. // ==/UserScript==
  12. "use strict";
  13.  
  14. const { addFloatButton } = {
  15. addFloatButton(text, onClick) /* 20200707-123713 */ {
  16. if (!document.addFloatButton) {
  17. const container = document.body
  18. .appendChild(document.createElement("div"))
  19. .attachShadow({ mode: "open" });
  20. container.innerHTML =
  21. "<style>:host{position:fixed;top:3px;left:3px;z-index:2147483647;height:0}#i{display:none}*{float:left;margin:4px;padding:1em;outline:0;border:0;border-radius:5px;background:#1e88e5;box-shadow:0 1px 4px rgba(0,0,0,.1);color:#fff;font-size:14px;line-height:0;transition:.3s}:active{background:#42a5f5;box-shadow:0 2px 5px rgba(0,0,0,.2)}button:active{transition:0s}:checked~button{visibility:hidden;opacity:0;transform:translateY(-3em)}label{border-radius:50%}:checked~label{opacity:.3;transform:translateY(3em)}</style><input id=i type=checkbox><label for=i></label>";
  22. document.addFloatButton = (text, onClick) => {
  23. const button = document.createElement("button");
  24. button.textContent = text;
  25. button.addEventListener("click", onClick);
  26. return container.appendChild(button);
  27. };
  28. }
  29. return document.addFloatButton(text, onClick);
  30. },
  31. };
  32.  
  33. addFloatButton("Save page", async function () {
  34. console.time("save page");
  35. this.style.background = "#ff9800";
  36. const interval = setInterval((o) => {
  37. const suffix = ".".padStart((++o.i % 3) + 1, " ").padEnd(3, " ");
  38. this.innerHTML = "Saving " + suffix.replace(/\s/g, "&nbsp;");
  39. }, ...[333, { i: 0 }]); // 茴回囘囬
  40.  
  41. const document = Object.freeze(window.document);
  42. const /** @type {Document} */ dom = document.cloneNode(true);
  43.  
  44. const removeList = `script, style, source, title, link[rel=stylesheet], link[rel=alternate], link[rel=search], link[rel*=pre], link[rel*=icon]`;
  45. dom.querySelectorAll(removeList).forEach((el) => el.remove());
  46.  
  47. const qsam = (s, f) => [...document.querySelectorAll(s)].map(f);
  48.  
  49. const imgs = dom.querySelectorAll("img");
  50. const imgTasks = qsam("img", async (el, i) => {
  51. const reader = new FileReader();
  52. reader.readAsDataURL(await (await fetch(el.currentSrc)).blob());
  53. await new Promise((r) => (reader.onload = reader.onerror = r));
  54. imgs[i].src = reader.result;
  55. imgs[i].srcset = "";
  56. });
  57.  
  58. const css = []; // Keep order
  59. const cssTasks = qsam("style, link[rel=stylesheet]", async (el, i) => {
  60. if (el.tagName === "STYLE") css[i] = el.textContent;
  61. else css[i] = await (await fetch(el.href)).text();
  62. });
  63.  
  64. await Promise.allSettled([...imgTasks, ...cssTasks]);
  65.  
  66. // [TODO:Limitation] `url()` and `image-set()` in css will not be save
  67. // Avoid the long-loading issue
  68. const cssStr = css.join("\n").replace(/(url|image-set)\(.*?\)/g, "url()");
  69. dom.head.appendChild(dom.createElement("style")).textContent = cssStr;
  70.  
  71. // [TODO:Limitation] breaked some no-doctype / xhtml / html4 pages
  72. const result = "<!DOCTYPE html>" + dom.documentElement.outerHTML;
  73.  
  74. const link = document.createElement("a"); // Using `dom` will cause failure
  75. link.download = `${document.title}_${Date.now()}.html`;
  76. link.href = "data:text/html," + encodeURIComponent(result);
  77. link.click();
  78.  
  79. console.timeEnd("save page");
  80.  
  81. clearInterval(interval);
  82. this.textContent = "Page saved";
  83. this.style.background = "#4caf50";
  84. });