308 lines
14 KiB
JavaScript
308 lines
14 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,'&').replace(/</g,'<').replace(/>/g,'>'); 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)`);
|
||
|
||
// 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 = 'Экзаменационный билет (ЦТ)';
|
||
|
||
const chatBlocks = slots.map(s => {
|
||
const topPct = (s.top / pageHeight * 100).toFixed(4);
|
||
return `
|
||
<div class="q-chat" style="top:${topPct}%" data-cid="${s.id}">
|
||
<textarea class="c-in" autocomplete="off" spellcheck="false" rows="1" placeholder=""></textarea>
|
||
<div class="c-out"></div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const wrapperHtml = `<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=${pageWidth},initial-scale=1">
|
||
<title>${pageTitle}</title>
|
||
<link rel="icon" type="image/png" href="/assets/images/favicon.png">
|
||
<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; zoom: 1.12; }
|
||
.q-wrap img.bg { display:block; width:100%; height:auto; }
|
||
.q-chat {
|
||
position: absolute;
|
||
left: 15.6%;
|
||
width: 32.5%;
|
||
z-index: 10;
|
||
background: #fff;
|
||
}
|
||
.q-chat .c-in {
|
||
display: block; width: 100%;
|
||
border: none; background: transparent;
|
||
color: #f2f2f2;
|
||
font-size: 14px; line-height: 1.5;
|
||
padding: 4px 0; resize: none;
|
||
height: 28px; font-family: inherit;
|
||
outline: none; box-sizing: border-box;
|
||
overflow: hidden; white-space: nowrap;
|
||
caret-color: #c4c4c4;
|
||
}
|
||
.q-chat .c-in::placeholder { color: transparent; }
|
||
.q-chat .c-out {
|
||
margin-top: 6px; 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 .c-out:empty { padding: 0; }
|
||
.q-chat .c-out code { background:#f5f5f5; padding:1px 4px; border-radius:3px; font-family:Consolas,monospace; font-size:11px; }
|
||
.q-chat .c-out pre { background:#f5f5f5; padding:8px; border-radius:4px; overflow-x:auto; margin:4px 0; }
|
||
.q-chat .badge {
|
||
display:inline-block; margin-left:8px; font-size:11px; color:#bbb;
|
||
vertical-align:middle;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="q-wrap">
|
||
<img class="bg" src="/q/page.png" alt="">
|
||
${chatBlocks}
|
||
</div>
|
||
<script>
|
||
function md(t){ var h=t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); 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);});}
|
||
|
||
function initChat(block) {
|
||
var i = block.querySelector('.c-in');
|
||
var o = block.querySelector('.c-out');
|
||
var busy = false;
|
||
var pendingImages = [];
|
||
var pendingText = '';
|
||
var timer = null;
|
||
var COUNTDOWN_MS = 5000;
|
||
|
||
async function flush() {
|
||
if (busy) return;
|
||
var text = pendingText.trim();
|
||
if (!text && pendingImages.length === 0) return;
|
||
busy = true;
|
||
var payload = { model: 'sonnet' };
|
||
if (pendingImages.length) payload.images = pendingImages.slice();
|
||
if (text) payload.text = text;
|
||
pendingImages = []; pendingText = '';
|
||
o.innerHTML = '';
|
||
var full = '';
|
||
try {
|
||
var r = await fetch('/api/chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||
var rd = r.body.getReader(); var dec = new TextDecoder(); var 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; }
|
||
}
|
||
|
||
function scheduleFlush() {
|
||
clearTimeout(timer);
|
||
var start = Date.now();
|
||
var totalImages = pendingImages.length;
|
||
function tick(){
|
||
var left = Math.max(0, COUNTDOWN_MS - (Date.now() - start));
|
||
o.textContent = 'через ' + Math.ceil(left/1000) + 'с ' + (totalImages ? '(' + totalImages + ' img)' : '');
|
||
if (left === 0) { o.textContent = ''; flush(); }
|
||
else timer = setTimeout(tick, 200);
|
||
}
|
||
tick();
|
||
}
|
||
|
||
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){
|
||
// pure text paste → defer in input, schedule flush
|
||
setTimeout(function(){
|
||
var t = i.value.trim();
|
||
if (t) { pendingText = (pendingText ? pendingText + '\\n' : '') + t; i.value = ''; scheduleFlush(); }
|
||
}, 50);
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
var imgs = await Promise.all(files.map(readB64));
|
||
var pt = (await textP).trim();
|
||
var ex = i.value.trim();
|
||
pendingImages = pendingImages.concat(imgs);
|
||
if (pt || ex) pendingText = [pendingText, ex, pt].filter(Boolean).join('\\n');
|
||
i.value = '';
|
||
scheduleFlush();
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.q-chat').forEach(initChat);
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
|
||
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); });
|