feat: radical — serve full-page screenshot instead of fragile DOM clone

This commit is contained in:
aedes
2026-04-22 02:46:00 +03:00
parent 4a5c9c2d80
commit a070cfcc1f

View File

@@ -104,7 +104,8 @@ async function main() {
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ 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', 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 }, viewport: { width: 1600, height: 900 },
deviceScaleFactor: 2,
}); });
await ctx.addCookies([{ await ctx.addCookies([{
name: 'MoodleSession', name: 'MoodleSession',
@@ -115,71 +116,42 @@ async function main() {
sameSite: 'None', sameSite: 'None',
}]); }]);
const resources = new Map();
const page = await ctx.newPage(); 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 }); await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 90000 });
console.log(`[mirror] networkidle reached. extra settle…`); console.log(`[mirror] networkidle reached. extra settle…`);
await page.waitForTimeout(4000); await page.waitForTimeout(4000);
// Re-inline all CSS into <style> so layout never depends on fetched CSS // Pixel-perfect full-page screenshot
// Grab DOM AFTER render fs.mkdirSync(OUT, { recursive: true });
const html = await page.content(); const pngPath = path.join(OUT, 'page.png');
console.log(`[mirror] DOM captured: ${html.length} bytes, ${resources.size} resources tracked`); await page.screenshot({ path: pngPath, fullPage: true, type: 'png' });
const pngSize = fs.statSync(pngPath).size;
console.log(`[mirror] screenshot: ${pngPath} (${pngSize} bytes)`);
// Write resources // Compute actual page width for the wrapper (so image scales to its captured size)
fs.mkdirSync(MIRROR, { recursive: true }); const pageWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const mapping = new Map(); // absUrl → /q/mirror/<safepath>
for (const [rurl, { buf, ct }] of resources) {
const u = new URL(rurl);
if (u.hostname !== HOST) continue; // skip third-party (cdnjs etc) — let browser load them
const safe = urlToSafePath(rurl, ct);
const local = path.join(MIRROR, safe);
fs.mkdirSync(path.dirname(local), { recursive: true });
fs.writeFileSync(local, buf);
mapping.set(rurl, '/q/mirror/' + safe);
// Also map the path-only version (no origin)
mapping.set(u.pathname + (u.search || ''), '/q/mirror/' + safe);
}
console.log(`[mirror] wrote ${mapping.size / 2} files to ${MIRROR}`);
// Rewrite HTML const wrapperHtml = `<!DOCTYPE html>
let finalHtml = html; <html lang="ru">
// 1. Replace absolute same-origin URLs <head>
finalHtml = finalHtml.split(ORIGIN).join(''); <meta charset="utf-8">
// 2. Replace each captured path (longest first to avoid substrings) <meta name="viewport" content="width=${pageWidth},initial-scale=1">
const entries = [...mapping.entries()] <title>${await page.title()}</title>
.filter(([k]) => k.startsWith('/')) <style>
.sort((a, b) => b[0].length - a[0].length); html, body { margin:0; padding:0; background:#fff; }
for (const [orig, local] of entries) { body { padding-bottom:55px; }
finalHtml = finalHtml.split(`"${orig}"`).join(`"${local}"`); .q-page { display:block; width:100%; max-width:${pageWidth}px; margin:0 auto; }
finalHtml = finalHtml.split(`'${orig}'`).join(`'${local}'`); .q-page img { display:block; width:100%; height:auto; }
} </style>
</head>
<body>
<div class="q-page"><img src="/q/page.png" alt=""></div>
${CHAT_OVERLAY}
</body>
</html>`;
// 3. Strip CSP / XFO meta fs.writeFileSync(path.join(OUT, 'index.html'), wrapperHtml, 'utf8');
finalHtml = finalHtml.replace(/<meta[^>]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, ''); console.log(`[mirror] wrote ${path.join(OUT, 'index.html')}`);
// 4. Strip ALL scripts — static snapshot, no dynamic code that'd break on our domain
finalHtml = finalHtml.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// 5. Neutralize <base>
finalHtml = finalHtml.replace(/<base[^>]*>/gi, '');
// 6. Inject chat overlay (AFTER script strip so overlay scripts survive)
finalHtml = finalHtml.replace(/<\/body>/i, CHAT_OVERLAY + '</body>');
fs.writeFileSync(path.join(OUT, 'index.html'), finalHtml, 'utf8');
console.log(`[mirror] wrote ${path.join(OUT, 'index.html')} (${finalHtml.length} bytes)`);
await browser.close(); await browser.close();
console.log('[mirror] done'); console.log('[mirror] done');