HTMLだけで配布するツールを公開していると、「名前を付けて保存」でHTML・CSS・JavaScriptがそのまま残ってしまうことがあります。
「名前を付けて保存」でダウンロードしたサイトが動かないようにする仕組みを制作中です。(ソースを表示で見えてしまいますが、少しだけマシだと思います。いずれはここも改善したいです)
現在、次のようなプラグインを試作しています。
最新バージョン
<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ファイルがダウンロードされるようになります)
<script src="https://uni928.github.io/Uni928PublicHTMLs/HTMLHiddenerLite/ver1.0.0.js"></script>
旧版2
<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
<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単体ツールを公開している人にとって便利だと思うので、もし興味のある方が公開・改善してくれたら嬉しいです。