feat: Playwright script to render 30-question chemistry quiz as page.png

This commit is contained in:
aedes
2026-05-21 12:03:49 +03:00
parent 24619fbf87
commit e4823f5687

607
scripts/generate-quiz.js Normal file
View File

@@ -0,0 +1,607 @@
// 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); });