Files
sechenov/scripts/mirror.js

247 lines
12 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 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 <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: 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)`);
// Decide gap position — mid-point between question 1 and question 2
let chatTop = Math.round(pageHeight / 3);
if (quePositions.length >= 2) {
chatTop = Math.round((quePositions[0].bottom + quePositions[1].top) / 2 - 30);
}
const pageTitle = (await page.title()).replace(/[<>]/g, '');
const wrapperHtml = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=${pageWidth},initial-scale=1">
<title>${pageTitle}</title>
<style>
html, body { margin:0; padding:0; background:#fff; font-family: Arial, sans-serif; }
.q-wrap { position: relative; width: 100%; max-width: ${pageWidth}px; margin: 0 auto; }
.q-wrap img.bg { display:block; width:100%; height:auto; }
.q-chat {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: ${chatTop}px;
width: 720px;
max-width: 94%;
z-index: 10;
background: #fff;
padding: 8px 0;
}
.q-chat textarea {
display: block;
width: 100%;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fff;
color: #333;
font-size: 14px;
line-height: 1.5;
padding: 8px 12px;
resize: none;
height: 34px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.q-chat textarea::placeholder { color: #bbb; }
.q-chat .out {
margin-top: 8px;
min-height: 0;
padding: 6px 12px;
font-size: 12px;
color: #888;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
font-family: Arial, sans-serif;
}
.q-chat .out:empty { padding: 0; }
.q-chat .out code { background:#f5f5f5; padding:1px 4px; border-radius:3px; font-family:Consolas,monospace; font-size:11px; }
.q-chat .out pre { background:#f5f5f5; padding:8px; border-radius:4px; overflow-x:auto; margin:4px 0; }
</style>
</head>
<body>
<div class="q-wrap">
<img class="bg" src="/q/page.png" alt="">
<div class="q-chat">
<textarea id="_chatIn" autocomplete="off" spellcheck="false" rows="1" placeholder=""></textarea>
<div class="out" id="_chatOut"></div>
</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(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>'); return h; }
function readB64(f){return new Promise(function(r){var fr=new FileReader();fr.onload=function(){r({data:fr.result.split(',')[1],media_type:f.type||'image/png'});};fr.readAsDataURL(f);});}
function gasp(it){return new Promise(function(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(readB64));var pt=(await textP).trim();var ex=i.value.trim();send({text:[ex,pt].filter(Boolean).join('\\n'),images:images});
});
})();
</script>
</body>
</html>`;
fs.writeFileSync(path.join(OUT, 'index.html'), wrapperHtml, 'utf8');
console.log(`[mirror] wrote ${path.join(OUT, 'index.html')} — chat at y=${chatTop}px`);
await browser.close();
console.log('[mirror] done');
}
main().catch(e => { console.error(e); process.exit(1); });