Files
sechenov/scripts/mirror.js

164 lines
8.3 KiB
JavaScript

// Usage: node scripts/mirror.js <url> <moodle-session-value> [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 <script> tags (static snapshot), and
// writes public/q/index.html + public/q/mirror/<path-tree>.
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const crypto = require('crypto');
const CHAT_OVERLAY = `
<style>
#_chatOut { position:fixed; left:0; right:0; bottom:46px; max-height:200px; overflow-y:auto; padding:4px 15px; font-size:10px; color:#c4c4c4; line-height:1.5; white-space:pre-wrap; word-break:break-word; background:linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.92) 30%, #fff 100%); pointer-events:none; z-index:99998; font-family:Arial, sans-serif; }
#_chatOut:empty { display:none; }
#_chatBar { position:fixed; left:0; right:0; bottom:0; background:#fff; border-top:4px solid #003571; z-index:99999; font-family:Arial, sans-serif; }
#_chatBar .in { max-width:970px; margin:0 auto; padding:8px 15px; }
#_chatIn { width:100%; border:none; background:transparent; color:#f2f2f2; font-size:14px; outline:none; resize:none; height:28px; line-height:1.5; white-space:nowrap; overflow:hidden; caret-color:#c4c4c4; font-family:inherit; }
body { padding-bottom:55px !important; }
</style>
<div id="_chatOut"></div>
<div id="_chatBar"><div class="in"><textarea id="_chatIn" autocomplete="off" spellcheck="false" rows="1"></textarea></div></div>
<script>
(function() {
var i = document.getElementById('_chatIn'), o = document.getElementById('_chatOut'), busy = false;
function md(t){ var h=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); h=h.replace(/\`\`\`[\\w]*\\n?([\\s\\S]*?)\`\`\`/g,function(_,c){return '<pre>'+c.trim()+'</pre>';}); h=h.replace(/\`([^\`]+)\`/g,'<code>$1</code>'); h=h.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>'); return h; }
async function readFileB64(f){return new Promise(r=>{var fr=new FileReader();fr.onload=()=>r({data:fr.result.split(',')[1],media_type:f.type||'image/png'});fr.readAsDataURL(f);});}
function gasp(it){return new Promise(r=>it.getAsString(r));}
async function send(p){if(busy)return;var ht=p.text&&p.text.trim();if(!ht&&!p.image&&!(p.images&&p.images.length))return;busy=true;o.innerHTML='';var full='';
try{var r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({model:'sonnet'},p))});var rd=r.body.getReader(),dec=new TextDecoder(),buf='';
while(true){var x=await rd.read();if(x.done)break;buf+=dec.decode(x.value,{stream:true});var parts=buf.split('\\n\\n');buf=parts.pop();
for(var k=0;k<parts.length;k++){var ln=parts[k];if(!ln.startsWith('data: '))continue;var raw=ln.slice(6).trim();if(raw==='[DONE]')continue;try{var obj=JSON.parse(raw);if(obj.text){full+=obj.text;o.innerHTML=md(full);}}catch(e){}}}}catch(e){}finally{busy=false;}}
i.addEventListener('paste',async function(e){
var items=Array.from((e.clipboardData||window.clipboardData)?.items||[]);
var files=[];var textP=Promise.resolve('');
for(var j=0;j<items.length;j++){var it=items[j];if(it.kind==='file'&&it.type.startsWith('image/'))files.push(it.getAsFile());else if(it.kind==='string'&&it.type==='text/plain')textP=gasp(it);}
if(files.length===0){setTimeout(function(){var t=i.value.trim();if(t)send({text:t});},50);return;}
e.preventDefault();var images=await Promise.all(files.map(readFileB64));var pt=(await textP).trim();var ex=i.value.trim();send({text:[ex,pt].filter(Boolean).join('\\n'),images:images});
});
})();
</script>
`;
function urlToSafePath(u) {
const parsed = new URL(u);
let p = parsed.pathname;
if (parsed.search) {
let safe = parsed.search.slice(1).replace(/[^a-zA-Z0-9._-]/g, '_');
// Hash overly long query strings to avoid ENAMETOOLONG
if (safe.length > 80) {
safe = crypto.createHash('md5').update(parsed.search).digest('hex').slice(0, 12);
}
p += '_q_' + safe;
}
// also hash if path itself is way too long
const basename = path.basename(p);
if (basename.length > 200) {
const ext = path.extname(p);
const dir = path.dirname(p);
p = dir + '/' + crypto.createHash('md5').update(basename).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 <url> <MoodleSession-value> [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();
if (buf && buf.length > 0) resources.set(rurl, buf);
} 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`);
// Write resources
fs.mkdirSync(MIRROR, { recursive: true });
const mapping = new Map(); // absUrl → /q/mirror/<safepath>
for (const [rurl, buf] 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);
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
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}'`);
}
// 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)`);
await browser.close();
console.log('[mirror] done');
}
main().catch(e => { console.error(e); process.exit(1); });