(async () => { const origin = location.origin; const pageUrl = new URL(location.href); const decoder = new TextDecoder(); const encoder = new TextEncoder(); const collected = new Map(); const fileMap = new Map(); const failed = []; const normalizeUrl = (u, base = location.href) => { try { const x = new URL(u, base); if (!/^https?:$/.test(x.protocol)) return null; if (x.origin !== origin) return null; x.hash = ""; return x; } catch { return null; } }; const hasExt = (name) => /\.[a-zA-Z0-9]{1,10}$/.test(name || ""); const toLocalPath = (urlObj, isHtml = false) => { let path = decodeURIComponent(urlObj.pathname || "/"); if (!path || path === "/") return "index.html"; const parts = path.split("/").filter(Boolean); const last = parts[parts.length - 1] || ""; if (isHtml) { if (!hasExt(last)) parts.push("index.html"); return parts.join("/"); } if (!hasExt(last)) parts.push("index.html"); return parts.join("/"); }; const dirname = (p) => { const i = p.lastIndexOf("/"); return i >= 0 ? p.slice(0, i) : ""; }; const relativePath = (fromFile, toFile) => { const fromDir = dirname(fromFile); const fromParts = fromDir ? fromDir.split("/") : []; const toParts = toFile.split("/"); let i = 0; while (i < fromParts.length && i < toParts.length && fromParts[i] === toParts[i]) { i++; } const up = new Array(fromParts.length - i).fill(".."); const down = toParts.slice(i); const out = [...up, ...down].join("/"); return out || "."; }; const addUrl = (u, type = "asset", base = location.href) => { const nu = normalizeUrl(u, base); if (!nu) return null; if (!collected.has(nu.href)) { collected.set(nu.href, { url: nu, type }); } return nu.href; }; const cssUrlRegex = /url\((['"]?)(.*?)\1\)/g; const extractCssUrls = (cssText, baseUrl) => { const out = []; let m; while ((m = cssUrlRegex.exec(cssText))) { const raw = (m[2] || "").trim(); if (!raw || raw.startsWith("data:") || raw.startsWith("blob:") || raw.startsWith("#")) continue; const nu = normalizeUrl(raw, baseUrl); if (nu) out.push(nu.href); } return out; }; addUrl(location.href, "html"); document.querySelectorAll("script[src]").forEach(el => addUrl(el.getAttribute("src"), "asset")); document.querySelectorAll("link[href]").forEach(el => addUrl(el.getAttribute("href"), "asset")); document.querySelectorAll("img[src]").forEach(el => addUrl(el.getAttribute("src"), "asset")); document.querySelectorAll("[style]").forEach(el => { const style = el.getAttribute("style") || ""; let m; while ((m = cssUrlRegex.exec(style))) { addUrl(m[2], "asset"); } }); performance.getEntriesByType("resource").forEach(r => addUrl(r.name, "asset")); const fetchBinary = async (url) => { const res = await fetch(url, { credentials: "include" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return new Uint8Array(await res.arrayBuffer()); }; const fetchText = async (url) => { const res = await fetch(url, { credentials: "include" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.text(); }; const pageLocalPath = toLocalPath(pageUrl, true); // 1) fetch all current collected files for (const { url, type } of [...collected.values()]) { const href = url.href; const localPath = toLocalPath(url, type === "html"); try { if (type === "html" && href === pageUrl.href) { continue; } const bytes = await fetchBinary(href); fileMap.set(href, { url: href, localPath, bytes, contentType: "", text: null }); console.log("saved:", localPath); } catch (e) { failed.push({ url: href, error: String(e) }); console.warn("failed:", href, e); } } // 2) bring CSS files as text, collect nested url(...) assets const cssCandidates = [...fileMap.values()].filter(f => /\.css(\?|$)/i.test(f.url) || /\.css$/i.test(f.localPath)); for (const f of cssCandidates) { try { const txt = decoder.decode(f.bytes); f.text = txt; const nested = extractCssUrls(txt, f.url); for (const u of nested) { addUrl(u, "asset", f.url); } } catch (e) { console.warn("css decode failed:", f.url, e); } } // 3) fetch newly found nested CSS assets for (const { url, type } of [...collected.values()]) { const href = url.href; if (href === pageUrl.href) continue; if (fileMap.has(href)) continue; const localPath = toLocalPath(url, type === "html"); try { const bytes = await fetchBinary(href); fileMap.set(href, { url: href, localPath, bytes, contentType: "", text: null }); console.log("saved nested:", localPath); } catch (e) { failed.push({ url: href, error: String(e) }); console.warn("failed nested:", href, e); } } // 4) rewrite CSS url(...) to local relative paths for (const f of [...fileMap.values()]) { if (!(/\.css(\?|$)/i.test(f.url) || /\.css$/i.test(f.localPath))) continue; try { let txt = f.text != null ? f.text : decoder.decode(f.bytes); txt = txt.replace(cssUrlRegex, (full, q, raw) => { const val = (raw || "").trim(); if (!val || val.startsWith("data:") || val.startsWith("blob:") || val.startsWith("#")) return full; const nu = normalizeUrl(val, f.url); if (!nu) return full; const target = fileMap.get(nu.href); if (!target) return full; const rel = relativePath(f.localPath, target.localPath); return `url("${rel}")`; }); f.bytes = encoder.encode(txt); f.text = txt; } catch (e) { console.warn("css rewrite failed:", f.url, e); } } // 5) rewrite HTML to local relative paths const htmlDoc = document.documentElement.cloneNode(true); const rewriteAttr = (selector, attr) => { htmlDoc.querySelectorAll(selector).forEach(el => { const raw = el.getAttribute(attr); if (!raw) return; if (raw.startsWith("data:") || raw.startsWith("blob:") || raw.startsWith("#")) return; const nu = normalizeUrl(raw, location.href); if (!nu) return; const target = fileMap.get(nu.href); if (!target) return; el.setAttribute(attr, relativePath(pageLocalPath, target.localPath)); }); }; rewriteAttr("script[src]", "src"); rewriteAttr("link[href]", "href"); rewriteAttr("img[src]", "src"); rewriteAttr("source[src]", "src"); rewriteAttr("video[src]", "src"); rewriteAttr("audio[src]", "src"); rewriteAttr("iframe[src]", "src"); rewriteAttr("a[href]", "href"); htmlDoc.querySelectorAll("[style]").forEach(el => { const style = el.getAttribute("style") || ""; const replaced = style.replace(cssUrlRegex, (full, q, raw) => { const val = (raw || "").trim(); if (!val || val.startsWith("data:") || val.startsWith("blob:") || val.startsWith("#")) return full; const nu = normalizeUrl(val, location.href); if (!nu) return full; const target = fileMap.get(nu.href); if (!target) return full; return `url("${relativePath(pageLocalPath, target.localPath)}")`; }); el.setAttribute("style", replaced); }); const htmlText = "\n" + htmlDoc.outerHTML; fileMap.set(pageUrl.href, { url: pageUrl.href, localPath: pageLocalPath, bytes: encoder.encode(htmlText), contentType: "text/html", text: htmlText }); const reportText = JSON.stringify({ page: location.href, savedPage: pageLocalPath, savedCount: fileMap.size, failedCount: failed.length, failed }, null, 2); fileMap.set("__REPORT__", { url: "__REPORT__", localPath: "_download_report.json", bytes: encoder.encode(reportText), contentType: "application/json", text: reportText }); // 6) TAR builder const padOctal = (num, width) => { const s = num.toString(8); return s.padStart(width - 1, "0") + "\0"; }; const writeString = (buf, offset, str, length) => { const bytes = encoder.encode(str); buf.set(bytes.slice(0, length), offset); }; const writeOctal = (buf, offset, num, length) => { writeString(buf, offset, padOctal(num, length), length); }; const tarHeader = (name, size, mtime = Math.floor(Date.now() / 1000), mode = 0o644) => { const buf = new Uint8Array(512); writeString(buf, 0, name, 100); writeOctal(buf, 100, mode, 8); writeOctal(buf, 108, 0, 8); writeOctal(buf, 116, 0, 8); writeOctal(buf, 124, size, 12); writeOctal(buf, 136, mtime, 12); for (let i = 148; i < 156; i++) buf[i] = 32; buf[156] = "0".charCodeAt(0); writeString(buf, 257, "ustar", 6); writeString(buf, 263, "00", 2); let sum = 0; for (let i = 0; i < 512; i++) sum += buf[i]; const chk = sum.toString(8).padStart(6, "0"); writeString(buf, 148, chk, 6); buf[154] = 0; buf[155] = 32; return buf; }; const tarParts = []; let totalBytes = 0; for (const f of [...fileMap.values()]) { const name = f.localPath; const body = f.bytes instanceof Uint8Array ? f.bytes : new Uint8Array(f.bytes); const header = tarHeader(name, body.length); tarParts.push(header); tarParts.push(body); totalBytes += 512 + body.length; const pad = (512 - (body.length % 512)) % 512; if (pad) { tarParts.push(new Uint8Array(pad)); totalBytes += pad; } } tarParts.push(new Uint8Array(1024)); totalBytes += 1024; const tarBlob = new Blob(tarParts, { type: "application/x-tar" }); const a = document.createElement("a"); a.href = URL.createObjectURL(tarBlob); const safeName = (document.title || "page").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 80) || "page"; a.download = `${safeName}.tar`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); console.log("DONE"); console.log("page:", pageLocalPath); console.log("saved:", fileMap.size); console.log("failed:", failed.length); })();