Mirror a site you own. One file, zero installs.
A single Node script that saves a site you own or are authorised to archive — your dashboards, your SaaS content, public docs, a migration export — to plain HTML, text and assets on disk, using your own logged-in session. No npm install, no dependencies. Node 20+ runs it as-is.
Run this only against sites you own or have explicit written permission to copy, and respect each site's terms of service and copyright. Your logged-in session is your authorisation — it does not make copying someone else's paid or private content lawful. The script installs nothing and phones nowhere: it sends requests only to the URLs you give it and writes only to the output folder you choose.
Is it safe? Check for yourself
It is a plain-text JavaScript file — a .mjs, not an executable. There is nothing to install and no build step, so there is no place for anything to hide. It imports only Node's own built-ins ( fs, path, url), makes network requests only to the site you point it at, and writes only under your output folder. The whole 366-line source is on this page — read it, or drop it into VirusTotal — before you run it.
What it does
- 1
Tries
sitemap.xml,sitemap_index.xmlandrobots.txtfor the fastest URL list, then breadth-first crawls from every seed, following links inhref,src,srcsetand CSSurl(). - 2
Fetches each page with your cookie or bearer token and mirrors the raw HTML to disk.
- 3
Extracts clean plain text, plus a Next.js RSC “flight” prose stream on React Server Components sites.
- 4
Mirrors every asset it sees — CSS, JS, fonts, images, audio, video — and mines each CSS file for more.
- 5
Detects auth failures (401 / 403 or suspiciously short bodies) and keeps going instead of crashing.
- 6
Resumable: every URL is written to
.cache/seen.jsonl, so killing the run and re-running picks up where it stopped.
Quick start
Grab your cookie from a logged-in tab, write a two-line config, run it.
# DevTools (F12) → Network → click any request to the site →
# Request Headers → copy the "cookie:" value
echo "sb-…-auth-token=…; other=…" > cookie.txt{
"name": "my-site",
"seeds": ["https://my-site.com/dashboard"],
"cookieFile": "cookie.txt",
"outDir": "output-my-site",
"maxPages": 500,
"delayMs": 500
}node universal-scrape.mjs
# or with no config file at all:
node universal-scrape.mjs --seed https://my-site.com/dashboard --cookie "sb-…=…" --max 500Config reference
Every field is optional except seeds. Full shape is in the source header.
| Field | What it does |
|---|---|
| seeds | Starting URLs. The crawl begins here plus anything found in the sitemap. Required (or --seed). |
| cookie / cookieFile | Your logged-in cookie string, inline or from a file. Sent verbatim as the Cookie header. |
| bearer / bearerFile | Bearer token, sent as Authorization: Bearer <token> for API-driven sites. |
| headers | Extra headers merged into every request (x-api-key, x-tenant-id, x-csrf-token…). |
| outDir | Where files go. Defaults to output-<name>. |
| include / exclude | Regexes matched on pathname + search. include whitelists; exclude skips. |
| maxPages | Hard ceiling on document fetches. Default 5000. Assets are counted separately. |
| delayMs | Sleep between requests. Default 500. Halved for asset requests. Raise it if you hit 429s. |
| sameHostOnly | Only follow links on the seed's host. Default true — keep it on unless you mean to walk the web. |
| assets / sitemap | Toggle asset mirroring and the sitemap probe. Both default true. |
CLI flags
Every flag overrides the matching config field.
| Flag | What it does |
|---|---|
| --seed <url> | Add a seed URL. Repeatable. Works with no config file at all. |
| --config <path> | Load config from a file other than scrape.config.json. |
| --out <dir> | Override outDir. |
| --max <n> | Override maxPages. |
| --cookie <str> / --bearer <tok> | Inline auth, skipping the files. |
| --no-assets / --no-sitemap | Skip media downloads / skip the sitemap probe. |
| --reset | Wipe .cache/seen.jsonl and start from scratch. |
| --verbose | Log every URL as it's fetched (default: every 10th). |
What lands on disk
URL paths mirror 1:1. Every HTML page gets three companions — raw, text, metadata — plus a flight stream on Next.js sites.
output/
index.json overall map + stats + config (auth redacted)
.cache/seen.jsonl one line per URL (used to resume)
<host>/
dashboard.html raw HTML, byte-for-byte
dashboard.txt tags stripped, entities decoded
dashboard.flight.txt Next.js RSC prose stream (when present)
dashboard.json { url, title, h1, bytes, textBytes }
_next/static/chunks/*.css assets keep their real path
_external/<other-host>/... cross-origin assets, grouped by hostWhen it misbehaves
| Symptom | What it does |
|---|---|
| Everything is 401 / 403 | Cookie expired. Log in again, grab a fresh cookie, replace cookie.txt, re-run — it resumes. |
| All responses come back tiny | Auth was missing or the token expired, so the site served an empty shell. Refresh the cookie; add a bearer token if the site needs both. |
| Rate limited (429) | Raise delayMs — 3000 is usually enough. It already retries with backoff. |
| It walked off to another site | sameHostOnly is true by default; keep it on. Content on a different subdomain won't be followed — add it as an extra seed. |
| Text includes nav and footer | .txt is a naive tag strip. Run the .html through a readability library as a post-process if you need article-only text. |
The source
Every line the download contains — the same bytes, read them here first. Adjust the reading controls to taste; they save in this browser.
// universal-scrape.mjs — one file, zero dependencies (Node 20+).// -----------------------------------------------------------------------------// Mirror a site you own or are authorised to archive — your own dashboards, your// SaaS content, public documentation, a migration export — to disk, using your own// logged-in session. Everything the browser can see with your cookie, this saves.//// AUTHORISED USE ONLY. Run this only against sites you own or have explicit// permission to copy, and respect each site's terms of service and copyright.// The tool sends requests only to the URLs you give it and writes only to the// output folder you choose. No dependencies, no telemetry, nothing phones home.//// DevLune · https://devlune.in · MIT licence. Use at your own risk.// -----------------------------------------------------------------------------// what it does:// 1) reads config from scrape.config.json (or CLI flags) — seeds, auth, filters// 2) enumerates URLs via sitemap.xml + BFS link discovery from seeds// 3) fetches every page with your cookie/bearer, mirrors HTML to disk// 4) extracts clean text + Next.js RSC flight prose// 5) mirrors CSS + JS + fonts + images + audio/video referenced from the HTML// 6) resumable: keeps state in .cache/seen.jsonl, safe to re-run//// usage:// node universal-scrape.mjs (uses scrape.config.json)// node universal-scrape.mjs --seed https://site/x (ad-hoc single seed)// node universal-scrape.mjs --config other.json//// scrape.config.json shape:// {// "name": "myproject",// "seeds": ["https://site.com/dashboard","https://site.com/courses"],// "cookieFile": "cookie.txt", // OR "cookie": "raw cookie string"// "bearerFile": "bearer.txt", // OR "bearer": "Bearer eyJ…"// "headers": { "x-api-key": "…" },// "outDir": "output",// "include": ["^/(courses|lessons|packs|articles)"], // regex on pathname// "exclude": ["/logout","/api/track","\\.png$"],// "maxPages": 5000,// "delayMs": 500,// "concurrency": 1, // sequential is safer for auth cookies// "assets": true, // mirror css/js/fonts/media// "sitemap": true, // try /sitemap.xml first// "sameHostOnly": true,// "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) …",// "referer": "https://site.com/dashboard",// "authProbe": { "loginPath": "/login", "minBodyBytes": 500 }// }// ----------------------------------------------------------------------------- import fs from 'node:fs/promises';import { existsSync, createWriteStream } from 'node:fs';import path from 'node:path';import { URL } from 'node:url';import { setTimeout as sleep } from 'node:timers/promises';import { parseArgs } from 'node:util'; // ─── args + config ──────────────────────────────────────────────────────────const { values: A } = parseArgs({ options: { config: { type: 'string', default: 'scrape.config.json' }, seed: { type: 'string', multiple: true }, out: { type: 'string' }, max: { type: 'string' }, cookie: { type: 'string' }, bearer: { type: 'string' }, 'no-assets': { type: 'boolean' }, 'no-sitemap': { type: 'boolean' }, reset: { type: 'boolean' }, verbose:{ type: 'boolean' }, },}); let cfg = {};try { cfg = JSON.parse(await fs.readFile(A.config, 'utf8')); }catch { if (!A.seed?.length) throw new Error(`no ${A.config} and no --seed passed`); } if (A.seed?.length) cfg.seeds = A.seed;if (A.out) cfg.outDir = A.out;if (A.max) cfg.maxPages = +A.max;if (A.cookie) cfg.cookie = A.cookie;if (A.bearer) cfg.bearer = A.bearer;if (A['no-assets']) cfg.assets = false;if (A['no-sitemap']) cfg.sitemap = false; // A config with no seeds (and no --seed) would blow up on `cfg.seeds[0]` below with an// opaque TypeError. Fail early with a message that says exactly what to fix.if (!cfg.seeds?.length) { console.error('No seeds. Add "seeds" to your config, or pass --seed <url>.'); process.exit(1);} cfg.name ??= new URL(cfg.seeds[0]).host.replace(/^www\./, '');cfg.outDir ??= 'output-' + cfg.name;cfg.maxPages ??= 5000;cfg.delayMs ??= 500;cfg.concurrency ??= 1;cfg.assets ??= true;cfg.sitemap ??= true;cfg.sameHostOnly ??= true;cfg.include ??= [];cfg.exclude ??= [];cfg.userAgent ??= 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36';cfg.referer ??= cfg.seeds[0];cfg.authProbe ??= { minBodyBytes: 500 }; // ─── auth loading ───────────────────────────────────────────────────────────if (!cfg.cookie && cfg.cookieFile && existsSync(cfg.cookieFile)) cfg.cookie = (await fs.readFile(cfg.cookieFile, 'utf8')).trim();if (!cfg.bearer && cfg.bearerFile && existsSync(cfg.bearerFile)) cfg.bearer = (await fs.readFile(cfg.bearerFile, 'utf8')).trim(); const AUTH_HEADERS = { 'user-agent': cfg.userAgent, 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.9', 'referer': cfg.referer, ...(cfg.cookie ? { 'cookie': cfg.cookie } : {}), ...(cfg.bearer ? { 'authorization': cfg.bearer.startsWith('Bearer ') ? cfg.bearer : `Bearer ${cfg.bearer}` } : {}), ...(cfg.headers || {}),}; const seedHosts = new Set(cfg.seeds.map(s => new URL(s).host));const CACHE_DIR = path.join(cfg.outDir, '.cache');await fs.mkdir(CACHE_DIR, { recursive: true });const SEEN_FILE = path.join(CACHE_DIR, 'seen.jsonl');if (A.reset && existsSync(SEEN_FILE)) await fs.unlink(SEEN_FILE); // ─── seen / resume ──────────────────────────────────────────────────────────const seen = new Map(); // url -> { status, bytes, ts }if (existsSync(SEEN_FILE)) { const raw = await fs.readFile(SEEN_FILE, 'utf8'); for (const line of raw.split('\n')) if (line.trim()) { try { const j = JSON.parse(line); seen.set(j.url, j); } catch {} } console.log('[resume] loaded', seen.size, 'seen entries');}const seenStream = createWriteStream(SEEN_FILE, { flags: 'a' });const markSeen = (rec) => { seen.set(rec.url, rec); seenStream.write(JSON.stringify(rec) + '\n'); }; // ─── URL policy ─────────────────────────────────────────────────────────────const INC = cfg.include.map(r => new RegExp(r));const EXC = cfg.exclude.map(r => new RegExp(r));const shouldFetch = (u) => { try { const url = new URL(u); if (cfg.sameHostOnly && !seedHosts.has(url.host)) return false; const p = url.pathname + url.search; if (EXC.some(rx => rx.test(p))) return false; if (INC.length && !INC.some(rx => rx.test(p))) return false; return true; } catch { return false; }}; // ─── HTTP with retries ──────────────────────────────────────────────────────async function get(u, tries = 3) { let lastErr; for (let i = 1; i <= tries; i++) { try { const controller = new AbortController(); const t = setTimeout(() => controller.abort(), 30000); const res = await fetch(u, { headers: AUTH_HEADERS, redirect: 'follow', signal: controller.signal }); clearTimeout(t); const buf = Buffer.from(await res.arrayBuffer()); if (res.status === 429) throw new Error('rate limited'); const isHtml = (res.headers.get('content-type') || '').includes('html'); if (res.status === 401 || res.status === 403) return { url: u, status: res.status, buf, headers: Object.fromEntries(res.headers), authFail: true }; if (isHtml && buf.length < (cfg.authProbe.minBodyBytes || 0) && !cfg.cookie && !cfg.bearer) { return { url: u, status: res.status, buf, headers: Object.fromEntries(res.headers), suspiciousShort: true }; } return { url: u, status: res.status, buf, headers: Object.fromEntries(res.headers) }; } catch (e) { lastErr = e; if (i < tries) await sleep(1000 * i); } } throw lastErr;} // ─── extract links + assets from HTML ───────────────────────────────────────const LINK_RX = /(?:href|action|src|data-href|data-url)\s*=\s*["']([^"'#]+)/gi;const CSS_URL_RX = /url\(["']?([^"')]+)["']?\)/gi;const SRCSET_RX = /srcset\s*=\s*["']([^"']+)["']/gi;function extractLinks(html, baseUrl) { const out = new Set(); let m; while ((m = LINK_RX.exec(html))) { try { out.add(new URL(m[1], baseUrl).toString()); } catch {} } while ((m = CSS_URL_RX.exec(html))) { try { out.add(new URL(m[1], baseUrl).toString()); } catch {} } while ((m = SRCSET_RX.exec(html))) { for (const part of m[1].split(',')) { const u = part.trim().split(/\s+/)[0]; try { out.add(new URL(u, baseUrl).toString()); } catch {} } } return [...out];} const ASSET_EXT = /\.(css|js|mjs|woff2?|ttf|otf|svg|png|jpe?g|gif|webp|avif|ico|mp3|mp4|m3u8|webm|ogg|pdf|json|xml|txt)($|\?)/i;const isAsset = (u) => ASSET_EXT.test(u) || /\/(_next\/static|_next\/image|fonts?|media|images?|audio|video|assets?)\//i.test(u);const isDocument = (u) => !isAsset(u); // ─── content extraction (Next.js RSC + generic) ─────────────────────────────function extractContent(html) { const flightChunks = []; const rx = /self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g; let m; while ((m = rx.exec(html))) { flightChunks.push(m[1] .replace(/\\n/g, '\n').replace(/\\r/g, '').replace(/\\t/g, '\t') .replace(/\\"/g, '"').replace(/\\\\/g, '\\') .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))); } const flight = flightChunks.join('\n'); const stripped = html .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '') .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '') .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<') .replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'") .replace(/'/g, "'").replace(///g, '/') .replace(/\s+\n/g, '\n').replace(/\n\s+/g, '\n').replace(/[ \t]+/g, ' ').trim(); const title = (html.match(/<title[^>]*>([^<]+)/i) || [])[1]?.trim() || null; const h1 = (html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) || [])[1]?.replace(/<[^>]+>/g, ' ').trim() || null; return { text: stripped, flight, title, h1 };} // ─── file layout: mirror URL path onto disk ─────────────────────────────────function urlToPath(u, root) { const url = new URL(u); let p = url.pathname; if (p.endsWith('/') || p === '') p += 'index'; let name = path.join(root, url.host, p); const q = url.search ? '__' + Buffer.from(url.search).toString('base64url').slice(0, 32) : ''; name = name.replace(/[?#].*$/, '') + q; return name.replace(/[<>:"|*]/g, '_');} async function writeArtifact(u, buf, headers) { const base = urlToPath(u, cfg.outDir); await fs.mkdir(path.dirname(base), { recursive: true }); const ct = (headers['content-type'] || '').toLowerCase(); if (ct.includes('html')) { await fs.writeFile(base + '.html', buf); const html = buf.toString('utf8'); const c = extractContent(html); if (c.text) await fs.writeFile(base + '.txt', c.text); if (c.flight && c.flight.length > 500) await fs.writeFile(base + '.flight.txt', c.flight); await fs.writeFile(base + '.json', JSON.stringify({ url: u, title: c.title, h1: c.h1, bytes: buf.length, textBytes: c.text.length }, null, 2)); return { html, extracted: c }; } await fs.writeFile(base, buf); return {};} // ─── sitemap enumeration ────────────────────────────────────────────────────async function trySitemap(hostBase) { const found = new Set(); for (const p of ['/sitemap.xml', '/sitemap_index.xml', '/robots.txt']) { try { const r = await get(hostBase + p); if (r.status !== 200) continue; const body = r.buf.toString('utf8'); const locs = [...body.matchAll(/<loc>([^<]+)/gi)].map(m => m[1]); const smaps = locs.filter(u => u.endsWith('.xml')); for (const u of locs) found.add(u); for (const sm of smaps) { try { const rr = await get(sm); const inner = rr.buf.toString('utf8'); for (const m of inner.matchAll(/<loc>([^<]+)/gi)) found.add(m[1]); } catch {} } if (p === '/robots.txt') { for (const m of body.matchAll(/Sitemap:\s*(\S+)/gi)) { try { const rr = await get(m[1]); for (const mm of rr.buf.toString('utf8').matchAll(/<loc>([^<]+)/gi)) found.add(mm[1]); } catch {} } } } catch {} } return [...found];} // ─── crawl loop ─────────────────────────────────────────────────────────────const docQueue = [];const assetQueue = new Set();const queued = new Set(); const enqueueDoc = (u) => { if (queued.has(u) || seen.has(u)) return; if (!shouldFetch(u)) return; queued.add(u); docQueue.push(u);};const enqueueAsset = (u) => { if (!seen.has(u)) assetQueue.add(u); }; for (const s of cfg.seeds) enqueueDoc(s); if (cfg.sitemap) { for (const host of seedHosts) { const base = 'https://' + host; console.log('[sitemap] probing', base); const found = await trySitemap(base); console.log('[sitemap] found', found.length, 'urls'); for (const u of found) enqueueDoc(u); }} const stats = { docs: 0, docErr: 0, assets: 0, assetErr: 0, authFail: 0, bytes: 0 };const startedAt = Date.now(); console.log(`[crawl] starting — ${docQueue.length} queued, max ${cfg.maxPages}`);while (docQueue.length && stats.docs < cfg.maxPages) { const u = docQueue.shift(); if (seen.has(u)) continue; try { const r = await get(u); stats.docs++; stats.bytes += r.buf.length; if (r.authFail) { stats.authFail++; console.warn(` [${stats.docs}] AUTH ${r.status} ${u}`); markSeen({ url: u, status: r.status, bytes: r.buf.length, ts: Date.now(), authFail: true }); continue; } const { html, extracted } = await writeArtifact(u, r.buf, r.headers); if (A.verbose || stats.docs % 10 === 0) console.log(` [${stats.docs}] ${r.status} ${r.buf.length.toString().padStart(7)}b ${extracted?.title ? '"'+extracted.title.slice(0,50)+'" ' : ''}${u}`); if (html) { const links = extractLinks(html, u); for (const l of links) { if (isDocument(l)) enqueueDoc(l); else if (cfg.assets) enqueueAsset(l); } } markSeen({ url: u, status: r.status, bytes: r.buf.length, ts: Date.now() }); } catch (e) { stats.docErr++; console.warn(` [ERR] ${u} — ${e.message}`); markSeen({ url: u, error: e.message, ts: Date.now() }); } await sleep(cfg.delayMs);} console.log(`[crawl] docs done — ${stats.docs} fetched, ${stats.docErr} errors, ${stats.authFail} auth-blocked`); // ─── assets ─────────────────────────────────────────────────────────────────if (cfg.assets && assetQueue.size) { console.log(`[assets] downloading ${assetQueue.size}…`); for (const u of assetQueue) { if (seen.has(u)) continue; try { const r = await get(u); stats.assets++; stats.bytes += r.buf.length; const base = urlToPath(u, cfg.outDir); await fs.mkdir(path.dirname(base), { recursive: true }); await fs.writeFile(base, r.buf); if (A.verbose || stats.assets % 25 === 0) console.log(` [asset ${stats.assets}] ${r.status} ${r.buf.length}b ${u.slice(0, 90)}`); // If asset is CSS, mine it for further asset URLs if ((r.headers['content-type'] || '').includes('css')) { for (const m of r.buf.toString('utf8').matchAll(CSS_URL_RX)) { try { enqueueAsset(new URL(m[1], u).toString()); } catch {} } } markSeen({ url: u, status: r.status, bytes: r.buf.length, ts: Date.now(), asset: true }); } catch (e) { stats.assetErr++; markSeen({ url: u, error: e.message, ts: Date.now(), asset: true }); } await sleep(cfg.delayMs / 2); }} // ─── index ──────────────────────────────────────────────────────────────────const idx = { name: cfg.name, generatedAt: new Date().toISOString(), stats, seeds: cfg.seeds, cfg: { ...cfg, cookie: !!cfg.cookie ? '[redacted]' : undefined, bearer: !!cfg.bearer ? '[redacted]' : undefined, headers: cfg.headers } };await fs.writeFile(path.join(cfg.outDir, 'index.json'), JSON.stringify(idx, null, 2));seenStream.end();console.log(`\n[done] ${stats.docs} docs, ${stats.assets} assets, ${(stats.bytes/1024/1024).toFixed(1)} MB, ${Math.round((Date.now()-startedAt)/1000)}s`);console.log(` → ${cfg.outDir}/ (index.json holds the map; re-run to resume)`);universal-scrape is provided as-is, under the MIT licence, with no warranty of any kind. You alone are responsible for how you use it and for holding the right to copy anything you point it at. A logged-in session is not permission: mirroring paid, private or third-party content you are not authorised to copy may breach a site's terms of service, copyright, or applicable law. DevLune accepts no liability for misuse or for any loss arising from its use. If in doubt, get written permission first.
MIT licence. Authored by DevLune. Reading controls are saved in this browser only — no account, nothing sent anywhere.