// Usage: node scripts/mirror.js [output-dir] // Renders the target page in headless Chromium with the cookie, waits for // networkidle, captures final DOM + every fetched resource, rewrites URLs to // point to the local mirror, strips `; function mimeExt(ct) { ct = (ct || '').split(';')[0].trim().toLowerCase(); if (ct.includes('css')) return '.css'; if (ct.includes('javascript') || ct.includes('ecmascript')) return '.js'; if (ct === 'image/png') return '.png'; if (ct === 'image/jpeg' || ct === 'image/jpg') return '.jpg'; if (ct === 'image/gif') return '.gif'; if (ct === 'image/svg+xml') return '.svg'; if (ct === 'image/webp') return '.webp'; if (ct === 'image/x-icon' || ct === 'image/vnd.microsoft.icon') return '.ico'; if (ct === 'font/woff2' || ct === 'application/font-woff2') return '.woff2'; if (ct === 'font/woff' || ct === 'application/font-woff') return '.woff'; if (ct === 'font/ttf' || ct === 'application/x-font-ttf') return '.ttf'; if (ct === 'font/otf') return '.otf'; if (ct.startsWith('text/html')) return '.html'; if (ct.startsWith('application/json')) return '.json'; return ''; } function urlToSafePath(u, contentType) { const parsed = new URL(u); let p = parsed.pathname; if (parsed.search) { let safe = parsed.search.slice(1).replace(/[^a-zA-Z0-9._-]/g, '_'); if (safe.length > 80) { safe = crypto.createHash('md5').update(parsed.search).digest('hex').slice(0, 12); } p += '_q_' + safe; } // Append correct extension if missing — so express.static sends proper Content-Type const baseName = path.basename(p); if (!/\.[a-z0-9]{2,5}$/i.test(baseName) && contentType) { const ext = mimeExt(contentType); if (ext) p += ext; } // Hash if too long const baseName2 = path.basename(p); if (baseName2.length > 200) { const ext = path.extname(p); const dir = path.dirname(p); p = dir + '/' + crypto.createHash('md5').update(baseName2).digest('hex').slice(0, 16) + ext; } return p.replace(/^\/+/, ''); } async function main() { const [targetUrl, cookieValue, outDir] = process.argv.slice(2); if (!targetUrl || !cookieValue) { console.error('Usage: node mirror.js [out=/app/public/q]'); process.exit(1); } const OUT = outDir || '/app/public/q'; const MIRROR = path.join(OUT, 'mirror'); const urlObj = new URL(targetUrl); const ORIGIN = urlObj.origin; const HOST = urlObj.hostname; console.log(`[mirror] launching chromium → ${targetUrl}`); const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', viewport: { width: 1440, height: 900 }, }); await ctx.addCookies([{ name: 'MoodleSession', value: cookieValue, domain: HOST, path: '/', secure: true, sameSite: 'None', }]); const resources = new Map(); const page = await ctx.newPage(); page.on('response', async (resp) => { const rurl = resp.url(); if (!rurl.startsWith('http')) return; try { const buf = await resp.body(); const ct = resp.headers()['content-type'] || ''; if (buf && buf.length > 0) resources.set(rurl, { buf, ct }); } catch (e) { /* redirect/204/etc */ } }); await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 90000 }); console.log(`[mirror] networkidle reached. extra settle…`); await page.waitForTimeout(4000); // Re-inline all CSS into