Files
sechenov/scripts/generate-quiz.js

608 lines
27 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.
// node scripts/generate-quiz.js [output-dir]
// Renders 30 Chemistry questions in Moodle Boost style via Playwright,
// produces public/q/page.png + public/q/index.html with chat slots.
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const OUT = process.argv[2] || '/app/public/q';
// ─── 30 questions from ФОС Фарм_ОХ.pdf ───────────────────────────────────────
const QUESTIONS = [
{
text: 'РОДОНАЧАЛЬНАЯ СТРУКТУРА СОЕДИНЕНИЯ <img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" style="display:none"> <span class="formula-note">[структура — задание 001 в PDF]</span>',
opts: ['октан','пентан','гексан','гептан'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ <span class="fm">CF<sub>3</sub>CHBrCl</span> ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ',
opts: ['1-бромо-2,2,2-трифторо-1-хлоропропан','1-бромо-1-хлоро-2,2,2-трифтороэтан','1,1,1-трифторо-2-бромо-2-хлороэтан','2-бромо-1,1,1-трифторо-2-хлороэтан'],
},
{
text: 'РОДОНАЧАЛЬНАЯ СТРУКТУРА СОЕДИНЕНИЯ <span class="formula-note">[структура — задание 003]</span>',
opts: ['пентан','гептан','октан','гексан'],
},
{
text: 'НАЗВАНИЕ ХЛОРОПРЕНА <span class="fm">CH<sub>2</sub>=CHCCl=CH<sub>2</sub></span> ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ',
opts: ['2-хлоробутадиен-1,3','3-хлоробутадиен-1,3','2-хлоробутадиен-1,4','2-хлоробутен-1,3'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 005]</span>',
opts: ['2-оксопентандиовая кислота','4-оксопентандиовая кислота','пентанон-2-диовая кислота','1,5-дикарбоксипентанон-2'],
},
{
text: 'НАЗВАНИЕ ПАСК ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ',
opts: ['4-амино-2-гидроксибензойная кислота','п-аминосалициловая кислота','2-гидрокси-4-аминобензойная кислота','2-гидрокси-6-амино-3-бензойная кислота'],
},
{
text: 'В МОЛЕКУЛЕ ПАНТОТЕНОВОЙ КИСЛОТЫ НЕ СОДЕРЖИТСЯ',
opts: ['аминогруппа','карбоксильная группа','первичная гидроксильная группа','вторичная гидроксильная группа'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 008]</span>',
opts: ['1,3-дигидроксипропанон','1,3-дигидрокси-2-оксопропан','1,3-диоксиацетон','дигидроксиацетон'],
},
{
text: 'ПЕРВИЧНЫЙ СПИРТ <span class="formula-note">[варианты — структурные формулы в задании 009]</span>',
opts: ['[структура спирта 1]','[структура спирта 2]','[структура спирта 3]','[структура спирта 4]'],
},
{
text: 'ВТОРИЧНЫЙ СПИРТ <span class="formula-note">[варианты — структурные формулы в задании 010]</span>',
opts: ['[структура спирта 1]','[структура спирта 2]','[структура спирта 3]','[структура спирта 4]'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 011]</span>',
opts: ['1-изопропил-4-метилциклогексадиен-1,3','1-изопропил-4-метилциклогексадиен-1,4','1-метил-4-изопропилциклогексадиен-1,4','1-метил-4-изопропилциклогексадиен-1,3'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 012]</span>',
opts: ['1-изопропил-4-метилциклогексадиен-1,4','1-изопропил-4-метилциклогексадиен-1,3','1-метил-4-изопропилциклогексадиен-1,4','1-метил-4-изопропилциклогексадиен-1,3'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 013]</span>',
opts: ['циклогексанон','циклогексанкарбальдегид','оксоциклогексан','циклогексаналь'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 014]</span>',
opts: ['циклогексанкарбальдегид','циклогексаналь','оксоциклогексан','циклогексанкарбоновая кислота'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО РАДИКАЛЬНО-ФУНКЦИОНАЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 015]</span>',
opts: ['диэтилсульфид','диэтилсульфоксид','диэтилсульфон','диэтиловый тиоэфир'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО РАДИКАЛЬНО-ФУНКЦИОНАЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 016]</span>',
opts: ['метилэтиловый эфир','метоксиэтан','метилэтилоксид','метилэтанол'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО РАДИКАЛЬНО-ФУНКЦИОНАЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[структура — задание 017]</span>',
opts: ['метилэтилкетон','метилэтиловый эфир','бутанон','этилацетат'],
},
{
text: 'РОДОНАЧАЛЬНАЯ СТРУКТУРА СОЕДИНЕНИЯ <span class="formula-note">[структура — задание 018]</span>',
opts: ['бензальдегид','фенол','толуол','бензол'],
},
{
text: 'НАЗВАНИЕ РАДИКАЛА <span class="fm">C<sub>6</sub>H<sub>5</sub>—</span>',
opts: ['фенил','бензил','винил','гексил'],
},
{
text: 'НАЗВАНИЕ РАДИКАЛА <span class="fm">C<sub>6</sub>H<sub>5</sub>CH<sub>2</sub>—</span>',
opts: ['бензил','фенил','винил','аллил'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ <span class="fm">C<sub>6</sub>H<sub>5</sub>CH(OH)CH<sub>2</sub>NH<sub>2</sub></span> ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ',
opts: ['2-амино-1-фенилэтанол','2-гидрокси-2-фенилэтанамин','2-фенил-1-аминоэтанол','2-амино-1-гидроксиэтилбензол'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ <span class="fm">C<sub>6</sub>H<sub>5</sub>CH<sub>2</sub>CH<sub>2</sub>NH<sub>2</sub></span> ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ',
opts: ['2-фенилэтанамин','2-фенил-1-аминоэтан','2-аминоэтилбензол','2-фенилэтиламин'],
},
{
text: 'НАЗВАНИЕ СОЕДИНЕНИЯ ПО ЗАМЕСТИТЕЛЬНОЙ НОМЕНКЛАТУРЕ <span class="formula-note">[нафталиновая структура — задание 023]</span>',
opts: ['4-Аминонафталин-1-карбоновая кислота','1-Аминонафталин-4-карбоновая кислота','5-Нитронафталин-1-карбоновая кислота','4-Нитронафталинкарбоновая кислота'],
},
{
text: 'ПРОЕКЦИОННАЯ ФОРМУЛА S-ИЗОМЕРА ЯБЛОЧНОЙ (ГИДРОКСИБУТАНДИОВОЙ) КИСЛОТЫ <span class="formula-note">[варианты — проекции, задание 023б]</span>',
opts: ['[проекция 1]','[проекция 2]','[проекция 3]','[проекция 4]'],
},
{
text: 'ПРОЕКЦИОННАЯ ФОРМУЛА <span class="fm">(S)-2-АМИНОПРОПАНОВОЙ КИСЛОТЫ</span> <span class="formula-note">[варианты — проекции, задание 024]</span>',
opts: ['[проекция 1]','[проекция 2]','[проекция 3]','[проекция 4]'],
},
{
text: 'ПРОЕКЦИОННАЯ ФОРМУЛА <span class="fm">(R)-2-АМИНО-3-ГИДРОКСИПРОПАНОВОЙ КИСЛОТЫ</span> <span class="formula-note">[варианты — проекции, задание 025]</span>',
opts: ['[проекция 1]','[проекция 2]','[проекция 3]','[проекция 4]'],
},
{
text: 'СТАРШИНСТВО ЗАМЕСТИТЕЛЕЙ У ХИРАЛЬНОГО АТОМА УГЛЕРОДА ПО R,S-НОМЕНКЛАТУРЕ УМЕНЬШАЕТСЯ В РЯДУ:',
opts: ['NH₂ &gt; CH₂SH &gt; COOH &gt; H','NH₂ &gt; COOH &gt; CH₂SH &gt; H','COOH &gt; NH₂ &gt; CH₂SH &gt; H','CH₂SH &gt; COOH &gt; NH₂ &gt; H'],
},
{
text: 'α-АМИНОКИСЛОТА, ИМЕЮЩАЯ R-КОНФИГУРАЦИЮ ЦЕНТРА ХИРАЛЬНОСТИ <span class="formula-note">[варианты — структуры, задание 027]</span>',
opts: ['[структура аминокислоты 1]','[структура аминокислоты 2]','[структура аминокислоты 3]','[структура аминокислоты 4]'],
},
{
text: 'СОЕДИНЕНИЕ, СПОСОБНОЕ СУЩЕСТВОВАТЬ В ВИДЕ ЦИС-ТРАНС-ИЗОМЕРОВ',
opts: ['<span class="fm">CH₃CH=CHCH₃</span>','<span class="fm">CH₂=CHCOOH</span>','<span class="fm">CH₃CH₂CH=C(CH₃)₂</span>','<span class="fm">CH₃CH=CH₂</span>'],
},
{
text: 'ФОРМУЛА <span class="fm">(S)-2-ХЛОРОПРОПАНОВОЙ КИСЛОТЫ</span> <span class="formula-note">[варианты — проекции, задание 030]</span>',
opts: ['[проекция 1]','[проекция 2]','[проекция 3]','[проекция 4]'],
},
];
// ─── HTML template ─────────────────────────────────────────────────────────────
function buildHtml(questions) {
const LETTERS = ['а','б','в','г'];
const qBlocks = questions.map((q, qi) => `
<div class="que multichoice" id="q${qi+1}">
<div class="info">
<h3 class="no">Вопрос&#160;${qi+1}</h3>
<div class="state">Пока нет<br>ответа</div>
<div class="grade">Балл: 1,00</div>
<div class="editquestion"></div>
</div>
<div class="content">
<div class="formulation clearfix">
<div class="qtext">${q.text}</div>
<div class="ablock">
<div class="prompt">Выберите один ответ:</div>
<div class="answer">
${q.opts.map((opt, oi) => `
<div class="r${oi % 2}">
<input type="radio" id="q${qi+1}_${oi}" name="q${qi+1}" value="${oi}">
<label for="q${qi+1}_${oi}"><span class="answernumber">${LETTERS[oi]})&#160;</span>${opt}</label>
</div>`).join('')}
</div>
</div>
</div>
</div>
</div>`).join('\n');
const navBtns = questions.map((_, i) => `<a class="qnbutton notyetanswered btn" href="#q${i+1}" title="Вопрос ${i+1}">${i+1}</a>`).join('\n ');
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1600,initial-scale=1">
<title>Экзаменационный билет (ЦТ)</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
<style>
/* ── Moodle Boost quiz theme clone ──────────────────────────────────── */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 0.9375rem;
color: #343a40;
background-color: #f8f8f8;
}
/* Header */
#page-header {
background: #fff;
border-bottom: 1px solid #dee2e6;
padding: 0;
}
.navbar-custom {
background-color: #003366 !important;
min-height: 50px;
}
.navbar-custom .navbar-brand,
.navbar-custom .nav-link {
color: rgba(255,255,255,.9) !important;
font-size: 0.875rem;
}
.navbar-custom .nav-link:hover { color: #fff !important; }
.site-header {
display: flex;
align-items: center;
padding: 12px 20px;
gap: 18px;
}
.site-logo { max-height: 72px; }
.site-name h1 {
font-size: 1.15rem;
font-weight: 600;
color: #003366;
margin: 0;
line-height: 1.3;
}
.site-name p {
font-size: 0.8rem;
color: #6c757d;
margin: 0;
}
/* Breadcrumb */
.breadcrumb { background: transparent; padding: 8px 0; font-size: 0.8rem; margin-bottom: 0; }
.breadcrumb-item+.breadcrumb-item::before { content: ""; }
/* Page layout */
#page-content { padding: 20px 0; }
#region-main-box { padding: 0; }
/* Quiz header */
.quiz-intro {
font-size: 1.3rem;
font-weight: 600;
color: #343a40;
margin-bottom: 16px;
}
/* Question blocks */
.que {
display: flex;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: .25rem;
margin-bottom: 12px;
overflow: hidden;
}
.que + .que { margin-top: 12px; }
.que .info {
min-width: 185px;
max-width: 185px;
padding: 12px 14px;
background-color: #f8f9fa;
border-right: 1px solid #dee2e6;
font-size: 0.8125rem;
}
.que .info .no {
font-size: 0.9375rem;
font-weight: 700;
color: #343a40;
margin-bottom: 6px;
}
.que .info .state {
color: #721c24;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: .25rem;
padding: 2px 6px;
font-size: 0.75rem;
margin-bottom: 5px;
display: inline-block;
}
.que .info .grade {
color: #6c757d;
font-size: 0.8125rem;
margin-top: 4px;
}
.que .content {
flex: 1;
padding: 14px 16px;
}
.que .qtext {
font-size: 0.9375rem;
font-weight: 600;
color: #212529;
margin-bottom: 12px;
line-height: 1.5;
}
.fm {
font-family: "Courier New", Courier, monospace;
font-weight: normal;
color: #004085;
background: #e8f0fe;
padding: 0 4px;
border-radius: 2px;
font-size: 0.9em;
}
.formula-note {
display: inline-block;
background: #fff3cd;
border: 1px dashed #ffc107;
border-radius: 3px;
padding: 1px 7px;
font-size: 0.75rem;
font-weight: normal;
color: #856404;
font-style: italic;
vertical-align: middle;
}
.que .ablock .prompt {
font-size: 0.875rem;
color: #6c757d;
margin-bottom: 6px;
}
.que .answer { margin-bottom: 4px; }
.que .answer .r0,
.que .answer .r1 {
padding: 4px 6px;
border-radius: 3px;
font-size: 0.9rem;
}
.que .answer .r0 { background: #f8f9fa; }
.que .answer .r1 { background: #fff; }
.que .answer label {
font-weight: normal;
margin-bottom: 0;
cursor: pointer;
line-height: 1.4;
vertical-align: middle;
}
.que .answer input[type=radio] { margin-right: 6px; vertical-align: middle; }
.que .answer .answernumber { color: #6c757d; font-weight: 600; }
/* Navigation sidebar */
#block-region-side-post { padding-left: 10px; }
.quiz-nav-block {
background: #fff;
border: 1px solid #dee2e6;
border-radius: .25rem;
position: sticky;
top: 10px;
}
.quiz-nav-block .card-header {
background: #f8f9fa;
font-weight: 600;
font-size: 0.875rem;
padding: 10px 14px;
border-bottom: 1px solid #dee2e6;
}
.quiz-nav-block .card-body { padding: 12px; }
.qnbutton {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin: 2px;
padding: 0;
font-size: 0.8125rem;
color: #343a40 !important;
background-color: #fff !important;
border: 1px solid #ced4da !important;
border-radius: .2rem !important;
text-decoration: none !important;
line-height: 1;
}
.qnbutton:hover {
background-color: #e9ecef !important;
text-decoration: none;
}
.nav-btns { margin-bottom: 10px; }
.nav-footer {
border-top: 1px solid #dee2e6;
padding-top: 10px;
margin-top: 4px;
}
.nav-footer a {
display: block;
font-size: 0.875rem;
color: #007bff;
text-decoration: none;
margin-bottom: 10px;
}
.nav-footer a:hover { text-decoration: underline; }
.timeleft {
font-size: 0.875rem;
color: #495057;
}
.timeleft .timevalue {
font-weight: 700;
color: #dc3545;
}
</style>
</head>
<body>
<!-- ── HEADER ────────────────────────────────────────────────────────────── -->
<div id="page-header">
<div class="site-header container-fluid">
<svg width="64" height="64" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="48" fill="#003366" opacity="0.1"/>
<text x="50" y="58" text-anchor="middle" font-size="36" fill="#003366" font-family="serif">С</text>
</svg>
<div class="site-name">
<h1>Сеченовский Университет</h1>
<p>Единый образовательный портал Сеченовского Университета</p>
</div>
</div>
<nav class="navbar navbar-expand navbar-custom px-3 py-0">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="#">Главная</a></li>
<li class="nav-item"><a class="nav-link" href="#">Мои курсы</a></li>
</ul>
</nav>
</div>
<!-- ── BREADCRUMB ────────────────────────────────────────────────────────── -->
<div class="container-fluid px-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#">Главная</a></li>
<li class="breadcrumb-item"><a href="#">Органическая химия</a></li>
<li class="breadcrumb-item"><a href="#">Промежуточная аттестация</a></li>
<li class="breadcrumb-item active">Тест</li>
</ol>
</nav>
</div>
<!-- ── MAIN CONTENT ──────────────────────────────────────────────────────── -->
<div class="container-fluid px-4" id="page-content">
<div class="row" id="region-main-box">
<!-- Questions -->
<div class="col-md-9 col-sm-8" id="region-main">
<div class="quiz-intro">Основы строения органических соединений</div>
<form method="post">
${qBlocks}
</form>
</div>
<!-- Sidebar -->
<div class="col-md-3 col-sm-4" id="block-region-side-post">
<div class="quiz-nav-block">
<div class="card-header">Навигация по тесту</div>
<div class="card-body">
<div class="nav-btns">
${navBtns}
</div>
<div class="nav-footer">
<a href="#">Закончить попытку...</a>
<div class="timeleft">Осталось время <span class="timevalue">0:40:00</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>`;
}
// ─── chat slot template (same as mirror.js) ───────────────────────────────────
const CHAT_SCRIPT = `
function md(t){var h=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');h=h.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>');h=h.replace(/\`([^\`]+)\`/g,'<code>$1</code>');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'),o=block.querySelector('.c-out');
var busy=false,pendingImages=[],pendingText='',timer=null,CD=5000;
async function flush(){
if(busy)return;var text=pendingText.trim();
if(!text&&pendingImages.length===0)return;
busy=true;var payload={model:'opus'};
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(),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){o.textContent='Ошибка: '+e.message;}finally{busy=false;}
}
function scheduleFlush(){
clearTimeout(timer);var start=Date.now(),totalImages=pendingImages.length;
function tick(){var left=Math.max(0,CD-(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=[],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){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(),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);
`;
// ─── main ─────────────────────────────────────────────────────────────────────
async function main() {
fs.mkdirSync(OUT, { recursive: true });
const html = buildHtml(QUESTIONS);
const htmlPath = path.join(OUT, '_render.html');
fs.writeFileSync(htmlPath, html, 'utf8');
console.log('[generate-quiz] launching chromium…');
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 Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1600, height: 900 },
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
await page.goto('file://' + htmlPath, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForTimeout(2000);
// Add 180px gap between questions for chat slots
await page.addStyleTag({ content: '.que + .que { margin-top: 180px !important; }' });
await page.waitForTimeout(500);
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(`[generate-quiz] ${pageWidth}x${pageHeight} CSS px | ${quePositions.length} questions`);
const pngPath = path.join(OUT, 'page.png');
await page.screenshot({ path: pngPath, fullPage: true, type: 'png' });
console.log(`[generate-quiz] screenshot: ${pngPath} (${fs.statSync(pngPath).size} bytes)`);
// Chat slots between each pair of questions (29 slots for 30 questions)
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('[generate-quiz] chat slots:', slots.length);
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('\n');
const indexHtml = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=${pageWidth},initial-scale=1">
<title>Экзаменационный билет (ЦТ)</title>
<link rel="icon" type="image/png" href="/assets/images/favicon.png">
<style>
html,body{margin:0;padding:0;background:#fff;}
.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:Arial,sans-serif;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:#555;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;}
</style>
</head>
<body>
<div class="q-wrap">
<img class="bg" src="/q/page.png" alt="">
${chatBlocks}
</div>
<script>
${CHAT_SCRIPT}
</script>
</body>
</html>`;
fs.writeFileSync(path.join(OUT, 'index.html'), indexHtml, 'utf8');
console.log(`[generate-quiz] wrote index.html with ${slots.length} chat slots`);
// Cleanup temp render file
fs.unlinkSync(htmlPath);
await browser.close();
console.log('[generate-quiz] done');
}
main().catch(e => { console.error(e); process.exit(1); });