Revert "feat: Playwright script to render 30-question chemistry quiz as page.png"
This reverts commit e4823f5687.
This commit is contained in:
@@ -1,607 +0,0 @@
|
||||
// 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>=CH−CCl=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₂ > −CH₂SH > −COOH > −H','−NH₂ > −COOH > −CH₂SH > −H','−COOH > −NH₂ > −CH₂SH > −H','−CH₂SH > −COOH > −NH₂ > −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">Вопрос ${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]}) </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,'&').replace(/</g,'<').replace(/>/g,'>');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); });
|
||||
Reference in New Issue
Block a user