feat: radical — serve full-page screenshot instead of fragile DOM clone
This commit is contained in:
@@ -104,7 +104,8 @@ async function main() {
|
||||
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 },
|
||||
viewport: { width: 1600, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
await ctx.addCookies([{
|
||||
name: 'MoodleSession',
|
||||
@@ -115,71 +116,42 @@ async function main() {
|
||||
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 <style> so layout never depends on fetched CSS
|
||||
// Grab DOM AFTER render
|
||||
const html = await page.content();
|
||||
console.log(`[mirror] DOM captured: ${html.length} bytes, ${resources.size} resources tracked`);
|
||||
// Pixel-perfect full-page screenshot
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const pngPath = path.join(OUT, 'page.png');
|
||||
await page.screenshot({ path: pngPath, fullPage: true, type: 'png' });
|
||||
const pngSize = fs.statSync(pngPath).size;
|
||||
console.log(`[mirror] screenshot: ${pngPath} (${pngSize} bytes)`);
|
||||
|
||||
// Write resources
|
||||
fs.mkdirSync(MIRROR, { recursive: true });
|
||||
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}`);
|
||||
// Compute actual page width for the wrapper (so image scales to its captured size)
|
||||
const pageWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
|
||||
// Rewrite HTML
|
||||
let finalHtml = html;
|
||||
// 1. Replace absolute same-origin URLs
|
||||
finalHtml = finalHtml.split(ORIGIN).join('');
|
||||
// 2. Replace each captured path (longest first to avoid substrings)
|
||||
const entries = [...mapping.entries()]
|
||||
.filter(([k]) => k.startsWith('/'))
|
||||
.sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [orig, local] of entries) {
|
||||
finalHtml = finalHtml.split(`"${orig}"`).join(`"${local}"`);
|
||||
finalHtml = finalHtml.split(`'${orig}'`).join(`'${local}'`);
|
||||
}
|
||||
const wrapperHtml = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=${pageWidth},initial-scale=1">
|
||||
<title>${await page.title()}</title>
|
||||
<style>
|
||||
html, body { margin:0; padding:0; background:#fff; }
|
||||
body { padding-bottom:55px; }
|
||||
.q-page { display:block; width:100%; max-width:${pageWidth}px; margin:0 auto; }
|
||||
.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
|
||||
finalHtml = finalHtml.replace(/<meta[^>]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '');
|
||||
|
||||
// 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)`);
|
||||
fs.writeFileSync(path.join(OUT, 'index.html'), wrapperHtml, 'utf8');
|
||||
console.log(`[mirror] wrote ${path.join(OUT, 'index.html')}`);
|
||||
|
||||
await browser.close();
|
||||
console.log('[mirror] done');
|
||||
|
||||
Reference in New Issue
Block a user