1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HTMLの「名前を付けて保存」対策として、CSS・JavaScriptをDOMから消すプラグイン

1
Last updated at Posted at 2026-07-01

HTMLだけで配布するツールを公開していると、「名前を付けて保存」でHTML・CSS・JavaScriptがそのまま残ってしまうことがあります。

「名前を付けて保存」でダウンロードしたサイトが動かないようにする仕組みを制作中です。(ソースを表示で見えてしまいますが、少しだけマシだと思います。いずれはここも改善したいです)

現在、次のようなプラグインを試作しています。

最新バージョン
body タグを閉じる直前に挿入するコード
<script>
(function () {
  "use strict";

  const selfScript = document.currentScript;

  function escapeCssString(value) {
    return String(value)
      .replace(/\\/g, "\\\\")
      .replace(/"/g, '\\"');
  }

  function absolutizeCssUrls(cssText, cssUrl) {
    const baseUrl = new URL(cssUrl, document.baseURI);

    return cssText.replace(
      /url\(\s*(['"]?)(?!data:|blob:|#|[a-z][a-z0-9+.-]*:|\/\/)([^'")]+)\1\s*\)/gi,
      function (_, quote, url) {
        try {
          const absoluteUrl = new URL(url.trim(), baseUrl).href;
          return `url("${escapeCssString(absoluteUrl)}")`;
        } catch (_) {
          return _;
        }
      }
    );
  }

  function wrapMedia(cssText, mediaText) {
    const media = String(mediaText || "").trim();

    if (!media || media.toLowerCase() === "all") {
      return cssText;
    }

    return `@media ${media} {\n${cssText}\n}`;
  }

  function isStylesheetLink(link) {
    const rel = (link.getAttribute("rel") || "")
      .toLowerCase()
      .split(/\s+/);

    return rel.includes("stylesheet");
  }

  function shouldMigrateStylesheet(link) {
    const rel = (link.getAttribute("rel") || "")
      .toLowerCase()
      .split(/\s+/);

    if (!rel.includes("stylesheet")) return false;
    if (rel.includes("alternate")) return false;
    if (link.disabled) return false;

    return true;
  }

  async function fetchCssFromLink(link) {
    if (!link.href) {
      return null;
    }

    try {
      const res = await fetch(link.href, {
        cache: "force-cache",
        credentials: "same-origin"
      });

      if (!res.ok) {
        return null;
      }

      let cssText = await res.text();

      // replaceSync() では @import が取り込まれないため、明示的に無効化します。
      cssText = cssText.replace(/@import\s+[^;]+;/gi, function (rule) {
        return `/* Removed unsupported import: ${rule} */`;
      });

      cssText = absolutizeCssUrls(cssText, link.href);
      cssText = wrapMedia(cssText, link.media && link.media.mediaText);

      return cssText;
    } catch (_) {
      return null;
    }
  }

  function collectInlineStyles() {
    const cssTexts = [];

    document.querySelectorAll("style").forEach(function (style) {
      if (!style.textContent) return;
      if (style.disabled) return;

      cssTexts.push(
        wrapMedia(style.textContent, style.media && style.media.mediaText)
      );
    });

    return cssTexts;
  }

  async function collectLinkedStyles() {
    const cssTexts = [];

    for (const link of document.querySelectorAll("link[rel~='stylesheet']")) {
      if (!shouldMigrateStylesheet(link)) {
        continue;
      }

      const cssText = await fetchCssFromLink(link);

      if (cssText) {
        cssTexts.push(cssText);
      } else {
        console.warn("Stylesheet could not be migrated and will be removed:", link.href);
      }
    }

    return cssTexts;
  }

function removeCssAndScriptsFromDom() {
  removeInlineEventHandlers();
  removeDangerousUrls();

  document.querySelectorAll("style").forEach(function (node) {
    node.remove();
  });

  document.querySelectorAll("link").forEach(function (link) {
    if (isStylesheetLink(link)) {
      link.remove();
    }
  });

  document.querySelectorAll("script").forEach(function (script) {
    script.remove();
  });

  selfScript?.remove();
}

function removeInlineEventHandlers() {
  document.querySelectorAll("*").forEach(function (el) {
    for (const attr of Array.from(el.attributes)) {
      if (/^on/i.test(attr.name)) {
        el.removeAttribute(attr.name);
      }
    }
  });
}

function removeDangerousUrls() {
  const urlAttrs = [
    "href",
    "src",
    "action",
    "formaction",
    "xlink:href"
  ];

  document.querySelectorAll("*").forEach(function (el) {
    for (const attrName of urlAttrs) {
      const value = el.getAttribute(attrName);
      if (!value) continue;

      const normalized = value.trim().replace(/[\u0000-\u001F\u007F\s]+/g, "");

      if (/^javascript:/i.test(normalized)) {
        el.removeAttribute(attrName);
      }
    }
  });
}

  async function run() {
    const cssTexts = [
      ...collectInlineStyles(),
      ...(await collectLinkedStyles())
    ];

    try {
      if (
        cssTexts.length &&
        "adoptedStyleSheets" in document &&
        typeof CSSStyleSheet !== "undefined"
      ) {
        const sheet = new CSSStyleSheet();
        sheet.replaceSync(cssTexts.join("\n\n"));

        document.adoptedStyleSheets = [
          ...document.adoptedStyleSheets,
          sheet
        ];
      }
    } catch (error) {
      console.warn("Failed to migrate stylesheets:", error);
    } finally {
      // 要件に従い、移行成否にかかわらず DOM 上の CSS / script を削除します。
      removeCssAndScriptsFromDom();
    }
  }

  if (document.readyState === "complete") {
    run();
  } else {
    addEventListener("load", run, { once: true });
  }
})();
</script>
プラグインっぽく改行空白削除版(3539文字)
<script>(function(w,d){"use strict";const p={run:run};w.StyleScriptCleaner=p;const selfScript=d.currentScript;function escapeCssString(value){return String(value).replace(/\\/g,"\\\\").replace(/"/g,'\\"')}function absolutizeCssUrls(cssText,cssUrl){const baseUrl=new URL(cssUrl,d.baseURI);return cssText.replace(/url\(\s*(['"]?)(?!data:|blob:|#|[a-z][a-z0-9+.-]*:|\/\/)([^'")]+)\1\s*\)/gi,function(_,quote,url){try{const absoluteUrl=new URL(url.trim(),baseUrl).href;return`url("${escapeCssString(absoluteUrl)}")`}catch(_){return _}})}function wrapMedia(cssText,mediaText){const media=String(mediaText||"").trim();if(!media||media.toLowerCase()==="all")return cssText;return`@media ${media}{${cssText}}`}function isStylesheetLink(link){const rel=(link.getAttribute("rel")||"").toLowerCase().split(/\s+/);return rel.includes("stylesheet")}function shouldMigrateStylesheet(link){const rel=(link.getAttribute("rel")||"").toLowerCase().split(/\s+/);if(!rel.includes("stylesheet"))return false;if(rel.includes("alternate"))return false;if(link.disabled)return false;return true}async function fetchCssFromLink(link){if(!link.href)return null;try{const res=await fetch(link.href,{cache:"force-cache",credentials:"same-origin"});if(!res.ok)return null;let cssText=await res.text();cssText=cssText.replace(/@import\s+[^;]+;/gi,function(rule){return`/* Removed unsupported import: ${rule} */`});cssText=absolutizeCssUrls(cssText,link.href);cssText=wrapMedia(cssText,link.media&&link.media.mediaText);return cssText}catch(_){return null}}function collectInlineStyles(){const cssTexts=[];d.querySelectorAll("style").forEach(function(style){if(!style.textContent)return;if(style.disabled)return;cssTexts.push(wrapMedia(style.textContent,style.media&&style.media.mediaText))});return cssTexts}async function collectLinkedStyles(){const cssTexts=[];for(const link of d.querySelectorAll("link[rel~='stylesheet']")){if(!shouldMigrateStylesheet(link))continue;const cssText=await fetchCssFromLink(link);if(cssText){cssTexts.push(cssText)}else{console.warn("Stylesheet could not be migrated and will be removed:",link.href)}}return cssTexts}function removeCssAndScriptsFromDom(){removeInlineEventHandlers();removeDangerousUrls();d.querySelectorAll("style").forEach(function(node){node.remove()});d.querySelectorAll("link").forEach(function(link){if(isStylesheetLink(link))link.remove()});d.querySelectorAll("script").forEach(function(script){script.remove()});selfScript?.remove()}function removeInlineEventHandlers(){d.querySelectorAll("*").forEach(function(el){for(const attr of Array.from(el.attributes)){if(/^on/i.test(attr.name))el.removeAttribute(attr.name)}})}function removeDangerousUrls(){const urlAttrs=["href","src","action","formaction","xlink:href"];d.querySelectorAll("*").forEach(function(el){for(const attrName of urlAttrs){const value=el.getAttribute(attrName);if(!value)continue;const normalized=value.trim().replace(/[\u0000-\u001F\u007F\s]+/g,"");if(/^javascript:/i.test(normalized))el.removeAttribute(attrName)}})}async function run(){const cssTexts=[...collectInlineStyles(),...(await collectLinkedStyles())];try{if(cssTexts.length&&"adoptedStyleSheets"in d&&typeof CSSStyleSheet!=="undefined"){const sheet=new CSSStyleSheet();sheet.replaceSync(cssTexts.join("\n\n"));d.adoptedStyleSheets=[...d.adoptedStyleSheets,sheet]}}catch(error){console.warn("Failed to migrate stylesheets:",error)}finally{removeCssAndScriptsFromDom()}}if(d.readyState==="complete"){run()}else{addEventListener("load",run,{once:true})}})(window,document);</script>

GitHubPages 上でも公開しています。(上記の3539文字を入力した方が、後から悪意あるコードをに変えられないため安全だと思いますが、念のため公開)(ただし名前を付けて保存をすると、この仕組みのjsファイルがダウンロードされるようになります)

GitHubPages
<script src="https://uni928.github.io/Uni928PublicHTMLs/HTMLHiddenerLite/ver1.0.0.js"></script>

旧版2
body タグを閉じる直前に挿入するコード
<script>
(function () {
  "use strict";

  const selfScript = document.currentScript;

  async function run() {
    const cssTexts = [];

    document.querySelectorAll("style").forEach(style => {
      if (style.textContent) cssTexts.push(style.textContent);
    });

    for (const link of document.querySelectorAll("link[rel~='stylesheet']")) {
      try {
        const res = await fetch(link.href, { cache: "force-cache" });
        if (res.ok) cssTexts.push(await res.text());
      } catch (_) {}
    }

    if (cssTexts.length && "adoptedStyleSheets" in document) {
      const sheet = new CSSStyleSheet();
      sheet.replaceSync(cssTexts.join("\n\n"));
      document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
    }

    document.querySelectorAll("style, link[rel~='stylesheet'], script").forEach(node => {
      node.remove();
    });

    selfScript?.remove();
  }

  addEventListener("load", run);
})();
</script>
プラグインっぽく改行空白削除版(764文字)
<script>(function(){"use strict";const selfScript=document.currentScript;async function run(){const cssTexts=[];document.querySelectorAll("style").forEach(style=>{if(style.textContent)cssTexts.push(style.textContent);});for(const link of document.querySelectorAll("link[rel~='stylesheet']")){try{const res=await fetch(link.href,{cache:"force-cache"});if(res.ok)cssTexts.push(await res.text());}catch(_){}}if(cssTexts.length&&"adoptedStyleSheets"in document){const sheet=new CSSStyleSheet();sheet.replaceSync(cssTexts.join("\n\n"));document.adoptedStyleSheets=[...document.adoptedStyleSheets,sheet];}document.querySelectorAll("style, link[rel~='stylesheet'], script").forEach(node=>{node.remove();});selfScript?.remove();}addEventListener("load",run);})();</script>

旧版1
body タグを閉じる直前に挿入するコード
<script data-rewrite-helper="true">
(function () {
  "use strict";

  async function moveCssToAdoptedStyleSheetsAndRemoveDomTags() {
    const cssTexts = [];

    // styleタグのCSSを退避
    document.querySelectorAll("style").forEach(function (style) {
      if (style.textContent) {
        cssTexts.push(style.textContent);
      }
    });

    // link rel="stylesheet" のCSSを取得して退避
    const links = Array.from(document.querySelectorAll("link[rel~='stylesheet']"));
    for (const link of links) {
      try {
        const res = await fetch(link.href, { cache: "force-cache" });
        if (res.ok) {
          cssTexts.push(await res.text());
        }
      } catch (_) {
        // CORS等で取得できない外部CSSは移せません
      }
    }

    // CSSStyleSheetへ再登録
    if (cssTexts.length) {
      const cssText = cssTexts.join("\n\n");

      if ("adoptedStyleSheets" in document && "replaceSync" in CSSStyleSheet.prototype) {
        try {
          const sheet = new CSSStyleSheet();
          sheet.replaceSync(cssText);
          document.adoptedStyleSheets = Array.from(document.adoptedStyleSheets || []).concat(sheet);
        } catch (_) {
          const style = document.createElement("style");
          style.textContent = cssText;
          style.setAttribute("data-rewrite-helper", "true");
          document.head.appendChild(style);
        }
      } else {
        const style = document.createElement("style");
        style.textContent = cssText;
        style.setAttribute("data-rewrite-helper", "true");
        document.head.appendChild(style);
      }
    }

    // 元のCSSタグだけ削除
    document.querySelectorAll("style, link[rel~='stylesheet']").forEach(function (node) {
      if (node.getAttribute("data-rewrite-helper") === "true") return;
      node.remove();
    });

    const helperScript = document.currentScript;

    // 1. 今回追加したscript以外を退避
    const scripts = Array.from(document.querySelectorAll("script"))
      .filter(function (script) {
        return true;
      })
      .map(function (script) {
        return {
          src: script.getAttribute("src"),
          text: script.textContent || "",
          attrs: Array.from(script.attributes).map(function (attr) {
            return { name: attr.name, value: attr.value };
          })
        };
      });

    // 2. 今回追加したscript以外をDOMから削除
    document.querySelectorAll("script").forEach(function (script) {
      script.remove();
    });

    // 3. 退避したscriptを再定義・再実行
    scripts.forEach(function (record) {
      const next = document.createElement("script");

      record.attrs.forEach(function (attr) {
        if (attr.name.toLowerCase() === "src") return;
        next.setAttribute(attr.name, attr.value);
      });

      if (record.src) {
        next.src = record.src;
      } else {
        next.textContent = record.text;
      }
if (!document.body) return;

// インラインscriptは再実行すると const / let の再宣言エラーになりやすいので除外
if (!record.src) {
  return;
}

// 既に同じsrcを再実行済みなら除外
window.__rewrittenScriptSrcSet = window.__rewrittenScriptSrcSet || new Set();

if (window.__rewrittenScriptSrcSet.has(record.src)) {
  return;
}

window.__rewrittenScriptSrcSet.add(record.src);

// 念のため、既にDOM上にある同一srcのscriptも除去
document.querySelectorAll("script[src]").forEach(function (script) {
  if (script.src === next.src) {
    script.remove();
  }
});

document.body.appendChild(next);

      // DOM上に残したくない場合は実行後に削除
      if (!record.src) {
        next.remove();
      } else {
        next.addEventListener("load", function () {
          next.remove();
        });
        next.addEventListener("error", function () {
          next.remove();
        });
      }
    });
  }

  window.addEventListener("load", function () {
    moveCssToAdoptedStyleSheetsAndRemoveDomTags();
  });
})();
</script>
プラグインっぽく改行空白削除版(2553文字)
<script data-rewrite-helper="true">(function(){"use strict";async function moveCssToAdoptedStyleSheetsAndRemoveDomTags(){const cssTexts=[];document.querySelectorAll("style").forEach(function(style){if(style.textContent){cssTexts.push(style.textContent);}});const links=Array.from(document.querySelectorAll("link[rel~='stylesheet']"));for(const link of links){try{const res=await fetch(link.href,{cache:"force-cache"});if(res.ok){cssTexts.push(await res.text());}}catch(_){}}if(cssTexts.length){const cssText=cssTexts.join("\n\n");if("adoptedStyleSheets"in document&&"replaceSync"in CSSStyleSheet.prototype){try{const sheet=new CSSStyleSheet();sheet.replaceSync(cssText);document.adoptedStyleSheets=Array.from(document.adoptedStyleSheets||[]).concat(sheet);}catch(_){const style=document.createElement("style");style.textContent=cssText;style.setAttribute("data-rewrite-helper","true");document.head.appendChild(style);}}else{const style=document.createElement("style");style.textContent=cssText;style.setAttribute("data-rewrite-helper","true");document.head.appendChild(style);}}document.querySelectorAll("style, link[rel~='stylesheet']").forEach(function(node){if(node.getAttribute("data-rewrite-helper")==="true")return;node.remove();});const helperScript=document.currentScript;const scripts=Array.from(document.querySelectorAll("script")).filter(function(script){return true;}).map(function(script){return{src:script.getAttribute("src"),text:script.textContent||"",attrs:Array.from(script.attributes).map(function(attr){return{name:attr.name,value:attr.value};})};});document.querySelectorAll("script").forEach(function(script){script.remove();});scripts.forEach(function(record){const next=document.createElement("script");record.attrs.forEach(function(attr){if(attr.name.toLowerCase()==="src")return;next.setAttribute(attr.name,attr.value);});if(record.src){next.src=record.src;}else{next.textContent=record.text;}if(!document.body)return;if(!record.src){return;}window.__rewrittenScriptSrcSet=window.__rewrittenScriptSrcSet||new Set();if(window.__rewrittenScriptSrcSet.has(record.src)){return;}window.__rewrittenScriptSrcSet.add(record.src);document.querySelectorAll("script[src]").forEach(function(script){if(script.src===next.src){script.remove();}});document.body.appendChild(next);if(!record.src){next.remove();}else{next.addEventListener("load",function(){next.remove();});next.addEventListener("error",function(){next.remove();});}});}window.addEventListener("load",function(){moveCssToAdoptedStyleSheetsAndRemoveDomTags();});})();</script>

下記のようなページになります。(名前を付けて保存を試して頂けると幸いです)

目的は、「名前を付けて保存」したときに、元のHTMLへCSS・JavaScriptをできるだけ残さないことです。完全な保護ではありませんが、HTML単体で配布するツールでは、一定の効果が期待できるかもしれません。

もし完成度が上がれば、外部JavaScriptとして公開し、

<script src="plugin.js"></script>

のように読み込むだけで使える形にしたいと考えています。

こういったプラグインは、HTML単体ツールを公開している人にとって便利だと思うので、もし興味のある方が公開・改善してくれたら嬉しいです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?