// 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: 1600, height: 900 }, deviceScaleFactor: 2, }); await ctx.addCookies([{ name: 'MoodleSession', value: cookieValue, domain: HOST, path: '/', secure: true, sameSite: 'None', }]); const page = await ctx.newPage(); await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 90000 }); console.log(`[mirror] networkidle reached. extra settle…`); await page.waitForTimeout(4000); // Inject a wide gap before every question after the first — room for our chat block await page.addStyleTag({ content: ` .que + .que, .que ~ .que { margin-top: 180px !important; } `}); await page.waitForTimeout(500); // Measure question block positions (document coords, CSS pixels) const quePositions = await page.$$eval('.que', els => els.map(el => { const r = el.getBoundingClientRect(); return { top: r.top + window.scrollY, bottom: r.top + window.scrollY + r.height }; }) ); const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight); const pageWidth = await page.evaluate(() => document.documentElement.scrollWidth); console.log(`[mirror] page ${pageWidth}x${pageHeight} CSS px | questions:`, quePositions); fs.mkdirSync(OUT, { recursive: true }); const pngPath = path.join(OUT, 'page.png'); await page.screenshot({ path: pngPath, fullPage: true, type: 'png' }); console.log(`[mirror] screenshot: ${pngPath} (${fs.statSync(pngPath).size} bytes)`); // One chat slot per gap (between Q1-Q2, between Q2-Q3, ...) const slots = []; for (let i = 0; i < quePositions.length - 1; i++) { slots.push({ id: i + 1, top: Math.round(quePositions[i].bottom + 20) }); } console.log(`[mirror] chat slots:`, slots); const pageTitle = (await page.title()).replace(/[<>]/g, ''); const chatBlocks = slots.map(s => `
`).join(''); const wrapperHtml = ` ${pageTitle}
${chatBlocks}
`; fs.writeFileSync(path.join(OUT, 'index.html'), wrapperHtml, 'utf8'); console.log(`[mirror] wrote ${path.join(OUT, 'index.html')} — ${slots.length} chat slot(s)`); await browser.close(); console.log('[mirror] done'); } main().catch(e => { console.error(e); process.exit(1); });