Files
sechenov/scripts/mirror.js

308 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)`);
// 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,'&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);});}
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); });