Last active
August 16, 2025 08:05
-
-
Save laocoi/df7c881913a7565ff71734a58a30c628 to your computer and use it in GitHub Desktop.
Inject JS code for download webpage html, js, css
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Quick usage
Open the page you want to save.
Open the DevTools Console (F12) → Console tab.
Paste the entire snippet above and press Enter.
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
containsCORS-opaque
, it means the CDN doesn’t allow reading the content via JavaScript. You can still:chrome.downloads
or background fetching to bypass the read restriction, orInline
<style>
/<script>
: These are kept as-is inindex.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.