Skip to content

Instantly share code, notes, and snippets.

@laocoi
Last active August 16, 2025 08:05
Show Gist options
  • Save laocoi/df7c881913a7565ff71734a58a30c628 to your computer and use it in GitHub Desktop.
Save laocoi/df7c881913a7565ff71734a58a30c628 to your computer and use it in GitHub Desktop.
Inject JS code for download webpage html, js, css
(async () => {
// ===== Utils =====
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const nowSlug = () => {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
};
const getDoctype = () => {
const d = document.doctype;
if (!d) return '<!DOCTYPE html>';
return `<!DOCTYPE ${d.name}${d.publicId ? ` PUBLIC "${d.publicId}"` : ''}${d.systemId ? ` "${d.systemId}"` : ''}>`;
};
const absURL = (u) => new URL(u, document.baseURI).href;
const sanitize = (s) => s.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
const pathFromURL = (url, type) => {
const u = new URL(url, document.baseURI);
const pathname = u.pathname === '/' ? 'root' : u.pathname.split('/').filter(Boolean).join('_');
const qs = u.search ? '_q-' + sanitize(u.search.slice(1)).slice(0,80) : '';
const base = sanitize(`${u.hostname}_${pathname}${qs}`);
const ext = type === 'css' ? '.css' : type === 'js' ? '.js' : '';
return `assets/${type}/${base}${ext}`;
};
// ===== Load JSZip (CDN) =====
async function loadJSZip() {
if (window.JSZip) return window.JSZip;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js';
s.onload = () => resolve();
s.onerror = () => reject(new Error('Failed to load JSZip'));
document.head.appendChild(s);
});
return window.JSZip;
}
// ===== Fetch helpers (CORS-safe where possible) =====
async function fetchText(url) {
try {
const res = await fetch(url, { credentials: 'omit' });
// opaque body cannot be read (CORS)
if (!res.ok || res.type === 'opaque') {
return { ok: false, status: res.status || 'opaque', text: null, reason: res.type === 'opaque' ? 'CORS-opaque' : `HTTP ${res.status}` };
}
const text = await res.text();
return { ok: true, status: res.status, text, reason: null };
} catch (e) {
return { ok: false, status: 'error', text: null, reason: e.message || String(e) };
}
}
// Try inlining 1-level @import inside CSS
async function inlineCssImports(cssText, baseUrl, reportItem) {
const importRe = /@import\s+(?:url\()?['"]?([^'")\s]+)['"]?\)?[^;]*;/gi;
let out = cssText;
const imports = [];
let m;
while ((m = importRe.exec(cssText)) !== null) {
const childUrl = absURL(new URL(m[1], baseUrl).href);
imports.push({ full: m[0], url: childUrl });
}
for (const imp of imports) {
const fetched = await fetchText(imp.url);
if (fetched.ok) {
out = out.replace(imp.full, `/* inlined from ${imp.url} */\n${fetched.text}\n/* end inline */`);
reportItem.children = reportItem.children || [];
reportItem.children.push({ url: imp.url, ok: true });
} else {
// keep original @import; log failure
reportItem.children = reportItem.children || [];
reportItem.children.push({ url: imp.url, ok: false, reason: fetched.reason, status: fetched.status });
}
}
return out;
}
// ===== Collect assets =====
const scripts = Array.from(document.querySelectorAll('script[src]'))
.map(el => ({ el, url: absURL(el.src), type: 'js' }));
const stylesheets = Array.from(document.querySelectorAll('link[rel="stylesheet"][href]'))
.map(el => ({ el, url: absURL(el.href), type: 'css' }));
// Deduplicate by URL
const byUrl = new Map();
for (const it of [...scripts, ...stylesheets]) {
if (!byUrl.has(it.url)) byUrl.set(it.url, it);
}
const assets = Array.from(byUrl.values());
// ===== Fetch assets =====
const report = { page: location.href, fetchedAt: new Date().toISOString(), items: [] };
const urlToLocal = new Map();
const fileEntries = []; // {path, content}
for (const a of assets) {
const localPath = pathFromURL(a.url, a.type);
const itemReport = { url: a.url, type: a.type, localPath };
report.items.push(itemReport);
const fetched = await fetchText(a.url);
if (fetched.ok) {
let content = fetched.text;
if (a.type === 'css') {
content = await inlineCssImports(content, a.url, itemReport);
}
fileEntries.push({ path: localPath, content });
urlToLocal.set(a.url, localPath);
itemReport.ok = true;
} else {
itemReport.ok = false;
itemReport.status = fetched.status;
itemReport.reason = fetched.reason;
// leave original URL in HTML for this asset
}
// Be polite to not hammer servers
await sleep(20);
}
// ===== Rewrite HTML =====
const parsed = new DOMParser().parseFromString(document.documentElement.outerHTML, 'text/html');
parsed.querySelectorAll('link[rel="stylesheet"][href]').forEach(el => {
const u = absURL(el.href);
if (urlToLocal.has(u)) el.setAttribute('href', urlToLocal.get(u));
});
parsed.querySelectorAll('script[src]').forEach(el => {
const u = absURL(el.src);
if (urlToLocal.has(u)) el.setAttribute('src', urlToLocal.get(u));
});
// Optional: strip integrity/crossorigin to avoid blocking local
parsed.querySelectorAll('link[rel="stylesheet"][integrity], script[integrity]').forEach(el => el.removeAttribute('integrity'));
parsed.querySelectorAll('link[rel="stylesheet"][crossorigin], script[crossorigin]').forEach(el => el.removeAttribute('crossorigin'));
const finalHTML = `${getDoctype()}\n${parsed.documentElement.outerHTML}`;
// Add index.html + report.json
fileEntries.push({ path: 'index.html', content: finalHTML });
fileEntries.push({ path: 'report.json', content: JSON.stringify(report, null, 2) });
// ===== Build ZIP and download =====
const JSZip = await loadJSZip();
const zip = new JSZip();
for (const f of fileEntries) {
zip.file(f.path, f.content);
}
const blob = await zip.generateAsync({ type: 'blob' });
const name = `${location.hostname}-${nowSlug()}.zip`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 2000);
console.log('[Downloader] Done. Files:', fileEntries.map(f => f.path));
console.log('[Downloader] Report:', report);
})();
@laocoi
Copy link
Author

laocoi commented Aug 16, 2025

Quick usage

  1. Open the page you want to save.

  2. Open the DevTools Console (F12) → Console tab.

  3. Paste the entire snippet above and press Enter.

  4. The browser will download a file named your-domain-YYYY-MM-DD_HH-mm-ss.zip containing:

    • index.html rewritten to use local files for any JS/CSS that could be downloaded,
    • assets/js/*, assets/css/*,
    • report.json (a detailed log, useful for debugging CORS issues).

Notes & extensions

  • CORS: If report.json contains CORS-opaque, it means the CDN doesn’t allow reading the content via JavaScript. You can still:

    • Run this script as a content script inside a Chrome Extension (with the proper host permissions) and use chrome.downloads or background fetching to bypass the read restriction, or
    • Set up an internal CORS proxy to download the content.
  • Inline <style>/<script>: These are kept as-is in index.html to avoid risks of changing execution order. If you need to extract them into separate files, an option can be added to automatically “extract inline” and replace them with corresponding <link>/<script> tags.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment