Рефакторинг: улучшение системы аутентификации и UI компонентов

- Удалены устаревшие файлы (mock.js, old-schedule.txt, loading-overlay.tsx)
- Переработана система аутентификации (login, logout, check-auth)
- Добавлен компонент toast для уведомлений
- Улучшен контекст загрузки (loading-context)
- Обновлен парсер расписания (schedule.ts)
- Улучшена админ-панель
- Обновлена документация (README.md)
- Старые файлы перемещены в директорию old/
This commit is contained in:
kilyabin
2025-11-28 00:29:46 +04:00
parent 24bb531dfb
commit 9df04745df
17 changed files with 511 additions and 117 deletions

View File

@@ -1,360 +0,0 @@
export const content = `<html><head><title>Расписание занятий</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link rel="StyleSheet" type="text/css" href="lib/css/wv_20230927.css">
</head>
<body bgcolor="#FFFFFF" text="#000000" link="#000000" vlink="#000000" alink="#ABB8DA" leftmargin="0" topmargin="0" marginwidth="0" marginheight="0">
<table border="0" cellpadding="0" cellspacing="0" width="100%" bgcolor="3481A6"><tbody><tr><td height="30" align="right" bgcolor="ffffff" colspan="2" background="lib/img/itf_ht/mn_top_bg.gif"><a href="?mnt=2"></a><a href="?mnt=3"></a><table border="0" cellpadding="0" cellspacing="0"><tbody><tr><td background="lib/img/itf_ht/mn_top_bg.gif" align="right" bgcolor="ffffff" colspan="1"><a href="?mn=2" class="t_on">&nbsp;Расписание занятий&nbsp;</a></td><td background="lib/img/itf_ht/mn_top_bg_btn.gif" align="right" bgcolor="ffffff"><img src="lib/img/itf_ht/mn_top_btn_r.gif"></td><td background="lib/img/itf_ht/mn_top_bg_btn.gif" align="right" bgcolor="ffffff"><a href="?mn=3" class="t_on">Расписание преподавателей</a></td><td background="lib/img/itf_ht/mn_top_bg_btn.gif" align="right" bgcolor="ffffff"><img src="lib/img/itf_ht/mn_top_btn_l.gif"><a href="?mnt=4"></a></td><td background="lib/img/itf_ht/mn_top_bg_btn.gif" align="right" bgcolor="ffffff"><img src="lib/img/itf_ht/mn_top_btn_r.gif"></td><td background="lib/img/itf_ht/mn_top_bg_btn.gif" align="right" bgcolor="ffffff"><a href="?mn=4" class="t_on"></a><a href="https://lk.ks.psuti.ru/std">Личный кабинет студента</a></td><td background="lib/img/itf_ht/mn_top_bg_btn.gif" align="right" bgcolor="ffffff"><img src="lib/img/itf_ht/mn_top_btn_l.gif"></td></tr></tbody></table></td></tr></tbody></table><center><a href="https://lk.ks.psuti.ru/lib/doc/lk_ks_psuti_manual.pdf" target="blank" class="t_green_14"><b><u><i>Инструкция: "Доступ в личный кабинет студента"</i></u></b></a><br>
<a href="https://disk.yandex.ru/d/ioxTvdQXkPpU5w" target="_blank"><u><i>Расписание занятий на 1 семестр 2023-2024 уч. год (версия для печати)</i></u></a><br>
<a href="https://disk.yandex.ru/i/WyzJ4DVeq5e_Vg" target="_blank"><u><i>График учебного процесса на 2023/2024 уч. год (версия для печати)</i></u></a><br>
</center><br>
<table border="0" cellpadding="0" cellspacing="0" width="100%" bgcolor="3481A6">
<tbody><tr><td height="20" bgcolor="dddddd" align="center" colspan="7"><h7>Расписание занятий</h7></td></tr>
<tr><td bgcolor="3481A6" align="center" colspan="7" background="lib/img/itf_ht/hr_b_01.gif"><img src="lib/img/itf_ht/hr_b_01.gif"></td></tr><tr><td height="10" bgcolor="ffffff" colspan="7" align="center">с 25.09.2023 по 01.10.2023
</td></tr>
<tr><td bgcolor="3481A6" align="center" colspan="7" background="lib/img/itf_ht/bg_h2_03.gif"><table border="0" cellpadding="0" cellspacing="0" bgcolor="ffffff">
<tbody><tr><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif"><a href="?mn=2&amp;obj=146&amp;wk=194"><img src="lib/img/itf_ht/frw_l.gif"></a></td><td height="20" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_03.gif"><a href="?mn=2&amp;obj=146&amp;wk=194"><h3>предыдущая неделя</h3></a></td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif">&nbsp;&nbsp;&nbsp;&nbsp;</td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif"><img src="lib/img/itf_ht/rbl04.gif"></td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif">&nbsp;&nbsp;&nbsp;&nbsp;</td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif"><img src="lib/img/itf_ht/rbl04.gif"></td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif">&nbsp;&nbsp;&nbsp;&nbsp;</td><td height="20" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_03.gif"><a href="?mn=2&amp;obj=146&amp;wk=196"><h3>следующая неделя</h3></a></td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif"><a href="?mn=2&amp;obj=146&amp;wk=196"><img src="lib/img/itf_ht/frw_r.gif"></a></td></tr></tbody></table></td></tr>
</tbody></table><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td height="20" bgcolor="ffffff" colspan="7">&nbsp;&nbsp;&nbsp;<b>ПС-7</b></td></tr>
<tr><td height="5" bgcolor="ffffff" colspan="7"></td></tr>
<tr><td height="20" bgcolor="C0D8E3" colspan="7"><table border="0" cellpadding="0" cellspacing="0" bgcolor="ffffff">
<tbody><tr><td bgcolor="3481A6"><h3>Понедельник 25.09.2023 / 4 неделя</h3></td><td bgcolor="C0D8E3"><img src="lib/img/itf_ht/days_bg.gif"></td></tr></tbody></table></td></tr>
<tr align="center"><td bgcolor="ffffff"><b>№ пары</b></td>
<td bgcolor="ffffff"><b>Время занятий</b></td>
<td bgcolor="ffffff"><b>Способ</b></td>
<td bgcolor="ffffff"><b>Дисциплина, преподаватель</b></td>
<td bgcolor="ffffff"><b>Тема занятия</b></td>
<td bgcolor="ffffff"><b>Ресурс</b></td>
<td bgcolor="ffffff"><b>Задание для выполнения</b></td>
</tr>
<tr align="center"><td bgcolor="ffffff">1</td>
<td bgcolor="ffffff">08:00 09:30
</td>
<td bgcolor="ffffff">Самостоятельная работа</td>
<td bgcolor="ffffff">Физика<br>Кусаева Зарина Владимировна<font class="t_ur2"><br>Л. Толстого, 23<br>Кабинет: 410-2</font></td>
<td bgcolor="ffffff">Силы в природе.</td>
<td bgcolor="ffffff"><b><a href="https://cloud.mail.ru/public/eDk2/eV51SaUoU" target="blank">Лекция Задачи</a></b><br></td>
<td bgcolor="ffffff">Сделать конспект по теме: выписать определения, формулы. Самостоятельно решить задачи.<br></td></tr>
<tr align="center"><td bgcolor="ffffff">2</td>
<td bgcolor="ffffff">09:40 11:10
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Математика<br>Амукова Светлана Николаевна<font class="t_ur2"><br>Л. Толстого, 23<br>Кабинет: 410-2</font></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><br></td></tr>
<tr align="center"><td bgcolor="ffffff">3</td>
<td bgcolor="ffffff">11:40 13:10
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Математика<br>Амукова Светлана Николаевна<font class="t_ur2"><br>Л. Толстого, 23<br>Кабинет: 410-2</font></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><br></td></tr>
<tr align="center"><td bgcolor="ffffff">4</td>
<td bgcolor="ffffff">13:20 14:50
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Химия<br>Тарасова Таисия Евгеньевна<font class="t_ur2"><br>Л. Толстого, 23<br>Кабинет: 410-2</font></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><br></td></tr>
<tr><td height="5" bgcolor="ffffff" colspan="7"></td></tr>
<tr><td height="20" bgcolor="C0D8E3" colspan="7"><table border="0" cellpadding="0" cellspacing="0" bgcolor="ffffff">
<tbody><tr><td bgcolor="3481A6"><h3>Вторник 26.09.2023 / 4 неделя</h3></td><td bgcolor="C0D8E3"><img src="lib/img/itf_ht/days_bg.gif"></td></tr></tbody></table></td></tr>
<tr align="center"><td bgcolor="ffffff"><b>№ пары</b></td>
<td bgcolor="ffffff"><b>Время занятий</b></td>
<td bgcolor="ffffff"><b>Способ</b></td>
<td bgcolor="ffffff"><b>Дисциплина, преподаватель</b></td>
<td bgcolor="ffffff"><b>Тема занятия</b></td>
<td bgcolor="ffffff"><b>Ресурс</b></td>
<td bgcolor="ffffff"><b>Задание для выполнения</b></td>
</tr>
<tr align="center"><td bgcolor="ffffff">3</td>
<td bgcolor="ffffff">11:40 13:10
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">История<br>Арефьев Андрей Андреевич<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 401</font></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><br></td></tr>
<tr align="center"><td bgcolor="ffffbb">4</td>
<td bgcolor="ffffbb">13:20 14:50
<br><a class="t_zm">дистанционно</a></td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb">Физическая культура<br> </td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb"><br></td></tr>
<tr align="center"><td bgcolor="ffffff">5</td>
<td bgcolor="ffffff">15:10 16:40
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Информатика<br>Ларионова Софья Николаевна<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 208</font></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><br></td></tr>
<tr><td height="5" bgcolor="ffffff" colspan="7"></td></tr>
<tr><td height="20" bgcolor="C0D8E3" colspan="7"><table border="0" cellpadding="0" cellspacing="0" bgcolor="ffffff">
<tbody><tr><td bgcolor="3481A6"><h3>Среда 27.09.2023 / 4 неделя</h3></td><td bgcolor="C0D8E3"><img src="lib/img/itf_ht/days_bg.gif"></td></tr></tbody></table></td></tr>
<tr align="center"><td bgcolor="ffffff"><b>№ пары</b></td>
<td bgcolor="ffffff"><b>Время занятий</b></td>
<td bgcolor="ffffff"><b>Способ</b></td>
<td bgcolor="ffffff"><b>Дисциплина, преподаватель</b></td>
<td bgcolor="ffffff"><b>Тема занятия</b></td>
<td bgcolor="ffffff"><b>Ресурс</b></td>
<td bgcolor="ffffff"><b>Задание для выполнения</b></td>
</tr>
<tr align="center"><td bgcolor="ffffff">3</td>
<td bgcolor="ffffff">11:40 13:10
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Физика<br>Кусаева Зарина Владимировна<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 310</font></td>
<td bgcolor="ffffff">Импульс. Закон сохранения импульса.</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Проверка конспекта и задач<br></td></tr>
<tr align="center"><td bgcolor="ffffbb">4</td>
<td bgcolor="ffffbb">13:20 14:50
<br><a class="t_zm">перенос с 02.10.23</a></td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb">Физика<br>Кусаева Зарина Владимировна<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 310</font></td>
<td bgcolor="ffffbb">Закон сохранения энергии</td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb"><br></td></tr>
<tr align="center"><td bgcolor="ffffff">5</td>
<td bgcolor="ffffff">15:10 16:40
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Иностранный язык<br>Карпеева Александра Сергеевна<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 234</font></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><br></td></tr>
<tr align="center"><td bgcolor="ffffbb">6</td>
<td bgcolor="ffffbb">16:50 18:20
<br><a class="t_zm">дистанционно</a></td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb">География<br> </td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb"><br></td></tr>
<tr><td height="5" bgcolor="ffffff" colspan="7"></td></tr>
<tr><td height="20" bgcolor="C0D8E3" colspan="7"><table border="0" cellpadding="0" cellspacing="0" bgcolor="ffffff">
<tbody><tr><td bgcolor="3481A6"><h3>Четверг 28.09.2023 / 4 неделя</h3></td><td bgcolor="C0D8E3"><img src="lib/img/itf_ht/days_bg.gif"></td></tr></tbody></table></td></tr>
<tr align="center"><td bgcolor="ffffff"><b>№ пары</b></td>
<td bgcolor="ffffff"><b>Время занятий</b></td>
<td bgcolor="ffffff"><b>Способ</b></td>
<td bgcolor="ffffff"><b>Дисциплина, преподаватель</b></td>
<td bgcolor="ffffff"><b>Тема занятия</b></td>
<td bgcolor="ffffff"><b>Ресурс</b></td>
<td bgcolor="ffffff"><b>Задание для выполнения</b></td>
</tr>
<tr align="center"><td bgcolor="ffffbb">2</td>
<td bgcolor="ffffbb">09:40 11:10
<br><a class="t_zm">дистанционно</a></td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb">География<br> </td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb"></td>
<td bgcolor="ffffbb"><br></td></tr>
<tr align="center"><td bgcolor="ffffff">3</td>
<td bgcolor="ffffff">11:40 13:10
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Математика<br>Амукова Светлана Николаевна<font class="t_ur2"><br>Л. Толстого, 23<br>Кабинет: 410-2</font></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><br></td></tr>
<tr align="center"><td bgcolor="ffffff">4</td>
<td bgcolor="ffffff">13:20 14:50
</td>
<td bgcolor="ffffff">Очно</td>
<td bgcolor="ffffff">Русский язык<br>Назарова Елена Федоровна<font class="t_ur2"><br>Л. Толстого, 23<br>Кабинет: 410-2</font></td>
<td bgcolor="ffffff">Признаки заимствованного слова. Практическая работа 2</td>
<td bgcolor="ffffff"><b><a href="https://cloud.mail.ru/public/7mwL/Ui6a7ftsP" target="blank">Практическая работа 2</a></b><br></td>
<td bgcolor="ffffff"><br></td></tr>
<tr align="center"><td bgcolor="ffffff">5</td>
<td bgcolor="ffffff">15:10 16:40
</td>
<td bgcolor="ffffff">Очно</td>
<td bgcolor="ffffff">Литература<br>Назарова Елена Федоровна<font class="t_ur2"><br>Л. Толстого, 23<br>Кабинет: 410-2</font></td>
<td bgcolor="ffffff">Роман И. А. Гончарова "Обломов"</td>
<td bgcolor="ffffff"><b><a href="https://cloud.mail.ru/public/5f3Y/vYGed416U" target="blank">лекция</a></b><br></td>
<td bgcolor="ffffff">пересказ лекции, ответить на вопросы викторины<br></td></tr>
<tr><td height="5" bgcolor="ffffff" colspan="7"></td></tr>
<tr><td height="20" bgcolor="C0D8E3" colspan="7"><table border="0" cellpadding="0" cellspacing="0" bgcolor="ffffff">
<tbody><tr><td bgcolor="3481A6"><h3>Пятница 29.09.2023 / 5 неделя</h3></td><td bgcolor="C0D8E3"><img src="lib/img/itf_ht/days_bg.gif"></td></tr></tbody></table></td></tr>
<tr align="center"><td bgcolor="ffffff"><b>№ пары</b></td>
<td bgcolor="ffffff"><b>Время занятий</b></td>
<td bgcolor="ffffff"><b>Способ</b></td>
<td bgcolor="ffffff"><b>Дисциплина, преподаватель</b></td>
<td bgcolor="ffffff"><b>Тема занятия</b></td>
<td bgcolor="ffffff"><b>Ресурс</b></td>
<td bgcolor="ffffff"><b>Задание для выполнения</b></td>
</tr>
<tr align="center"><td bgcolor="ffffff">2</td>
<td bgcolor="ffffff">09:40 11:10
</td>
<td bgcolor="ffffff">Очно</td>
<td bgcolor="ffffff">Русский язык<br>Назарова Елена Федоровна<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 324</font></td>
<td bgcolor="ffffff">Язык как система</td>
<td bgcolor="ffffff"><b><a href="https://cloud.mail.ru/public/EBc5/MYaGgXqKe" target="blank">презентация</a></b><br></td>
<td bgcolor="ffffff">изучить презентацию, выучить определения<br></td></tr>
<tr align="center"><td bgcolor="ffffff">3</td>
<td bgcolor="ffffff">11:40 13:10
</td>
<td bgcolor="ffffff">Очно</td>
<td bgcolor="ffffff">Литература<br>Назарова Елена Федоровна<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 321</font></td>
<td bgcolor="ffffff">"Илья Ильич Обломов как временной тип и одна из граней национального характера"</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">написать сочинение на тему "Что во мне есть от Обломова"<br></td></tr>
<tr><td height="5" bgcolor="ffffff" colspan="7"></td></tr>
<tr><td height="20" bgcolor="C0D8E3" colspan="7"><table border="0" cellpadding="0" cellspacing="0" bgcolor="ffffff">
<tbody><tr><td bgcolor="3481A6"><h3>Суббота 30.09.2023 / 5 неделя</h3></td><td bgcolor="C0D8E3"><img src="lib/img/itf_ht/days_bg.gif"></td></tr></tbody></table></td></tr>
<tr align="center"><td bgcolor="ffffff"><b>№ пары</b></td>
<td bgcolor="ffffff"><b>Время занятий</b></td>
<td bgcolor="ffffff"><b>Способ</b></td>
<td bgcolor="ffffff"><b>Дисциплина, преподаватель</b></td>
<td bgcolor="ffffff"><b>Тема занятия</b></td>
<td bgcolor="ffffff"><b>Ресурс</b></td>
<td bgcolor="ffffff"><b>Задание для выполнения</b></td>
</tr>
<tr align="center"><td bgcolor="ffffff">1</td>
<td bgcolor="ffffff">08:00 09:30
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">Основы безопасности жизнедеятельности<br>Корнилова Светлана Александровна<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 110</font></td>
<td bgcolor="ffffff">Пр№1 Обеспечение личной безопасности на дорогах</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">оформить отчет<br></td></tr>
<tr align="center"><td bgcolor="ffffff">2</td>
<td bgcolor="ffffff">09:40 11:10
</td>
<td bgcolor="ffffff">Очно</td>
<td bgcolor="ffffff">Литература<br>Назарова Елена Федоровна<font class="t_green_10"><br>Московское шоссе, 120<br>Кабинет: 321</font></td>
<td bgcolor="ffffff">Новый герой, «отрицающий все», в романе И. С. Тургенева (1818- 1883) «Отцы и дети»</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff">читать роман "Отцы и дети"<br></td></tr>
<tr align="center"><td bgcolor="ffffff">3</td>
<td bgcolor="ffffff">11:30 13:00
</td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><i>Свободное время</i><br> </td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"></td>
<td bgcolor="ffffff"><br></td></tr>
<tr><td height="5" bgcolor="ffffff" colspan="7"></td></tr>
</tbody></table><table border="0" cellpadding="0" cellspacing="0" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="3481A6" align="center" colspan="7" background="lib/img/itf_ht/bg_h2_03.gif"><table border="0" cellpadding="0" cellspacing="0" bgcolor="ffffff">
<tbody><tr><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif"><a href="?mn=2&amp;obj=146&amp;wk=194"><img src="lib/img/itf_ht/frw_l.gif"></a></td><td height="20" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_03.gif"><a href="?mn=2&amp;obj=146&amp;wk=194"><h3>предыдущая неделя</h3></a></td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif">&nbsp;&nbsp;&nbsp;&nbsp;</td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif"><img src="lib/img/itf_ht/rbl04.gif"></td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif">&nbsp;&nbsp;&nbsp;&nbsp;</td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif"><img src="lib/img/itf_ht/rbl04.gif"></td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif">&nbsp;&nbsp;&nbsp;&nbsp;</td><td height="20" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_03.gif"><a href="?mn=2&amp;obj=146&amp;wk=196"><h3>следующая неделя</h3></a></td><td width="1" valign="bottom" background="lib/img/itf_ht/bg_h2_03.gif"><a href="?mn=2&amp;obj=146&amp;wk=196"><img src="lib/img/itf_ht/frw_r.gif"></a></td></tr></tbody></table></td></tr>
</tbody></table><br><table border="0" cellpadding="0" cellspacing="0" width="100%" bgcolor="ffffff">
<tbody><tr><td height="20" bgcolor="dddddd" align="center" colspan="8"><h7>Дневное отделение:</h7></td></tr>
<tr><td bgcolor="3481A6" align="center" colspan="8" background="lib/img/itf_ht/hr_b_01.gif"><img src="lib/img/itf_ht/hr_b_01.gif"></td></tr><tr><td height="20" bgcolor="3481A6" align="center" colspan="8" background="lib/img/itf_ht/bg_h2_03.gif"><h3>Выберите группу:</h3></td></tr>
<tr><td bgcolor="3481A6" align="center" colspan="8" background="lib/img/itf_ht/hr_b_03.gif"><img src="lib/img/itf_ht/hr_b_03.gif"></td></tr><tr><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td></tr><tr><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td><td height="20" width="25%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Первый курс:</h3></td><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td><td height="20" width="25%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Второй курс:</h3></td><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td><td height="20" width="25%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Третий курс:</h3></td><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td><td height="20" width="25%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Четвертый курс:</h3></td></tr>
<tr><td width="1" valign="bottom"></td><td width="25%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=144"><b>ИБ-5</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=145"><b>ИБ-6</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=152"><b>ИКС-6</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=153"><b>ИКС-7к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=150"><b>ИСПВ-6</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=151"><b>ИСПВ-7</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=161"><b>ИСПВ-8к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=154"><b>ИСПИ-5</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=155"><b>ИСПИ-6к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=156"><b>ИСПП-22</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=157"><b>ИСПП-23</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=158"><b>ИСПП-24к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=159"><b>ИСПП-25к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=160"><b>ИСПП-26к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=163"><b>ИСПП-27к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=146"><b>ПС-7</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=162"><b>СБ-1к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=147"><b>ССА-10</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=148"><b>ССА-11</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
<td width="1" valign="bottom"></td><td width="25%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=123"><b>ИБ-3</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=138"><b>ИБ-4к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=124"><b>ИКС-4</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=126"><b>ИКС-5к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=127"><b>ИСПВ-4</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=128"><b>ИСПВ-5к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=129"><b>ИСПИ-3</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=130"><b>ИСПИ-4к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=131"><b>ИСПП-16</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=132"><b>ИСПП-17</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=133"><b>ИСПП-18к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=134"><b>ИСПП-19к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=140"><b>ИСПП-20к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=141"><b>ИСПП-21к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=135"><b>ПС-6</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=136"><b>ССА-7</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=137"><b>ССА-8к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=149"><b>ССА-9к</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
<td width="1" valign="bottom"></td><td width="25%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=114"><b>ИБ-1к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=115"><b>ИБ-2к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=117"><b>ИКС-2</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=121"><b>ИКС-3к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=111"><b>ИСПВ-1</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=112"><b>ИСПВ-2к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=113"><b>ИСПВ-3к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=109"><b>ИСПИ-1</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=110"><b>ИСПИ-2к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=105"><b>ИСПП-11</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=106"><b>ИСПП-12</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=107"><b>ИСПП-13к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=108"><b>ИСПП-14к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=122"><b>ИСПП-15к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=116"><b>ПС-5</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=102"><b>ССА-4</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=103"><b>ССА-5</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=104"><b>ССА-6к</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
<td width="1" valign="bottom"></td><td width="25%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=101"><b>ИКС-1</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=67"><b>ИС-21</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=68"><b>ИС-22</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=70"><b>ИСПП-5</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=71"><b>ИСПП-6</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=72"><b>ИСПП-7к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=73"><b>ИСПП-8к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=74"><b>ИСПП-9к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=75"><b>МТС-78</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=76"><b>ПКС-33</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=77"><b>ПКС-34</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=78"><b>ПКС-35к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=80"><b>СК-69</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=81"><b>ССА-1к</b></a> </td></tr>
<tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=100"><b>ССА-3к</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
</tr>
</tbody></table><br><table border="0" cellpadding="0" cellspacing="0" width="100%" bgcolor="ffffff">
<tbody><tr><td height="20" bgcolor="dddddd" align="center" colspan="12"><h7>Заочное отделение:</h7></td></tr>
<tr><td bgcolor="3481A6" align="center" colspan="12" background="lib/img/itf_ht/hr_b_01.gif"><img src="lib/img/itf_ht/hr_b_01.gif"></td></tr><tr><td height="20" bgcolor="3481A6" align="center" colspan="12" background="lib/img/itf_ht/bg_h2_03.gif"><h3>Выберите группу:</h3></td></tr>
<tr><td bgcolor="3481A6" align="center" colspan="12" background="lib/img/itf_ht/hr_b_03.gif"><img src="lib/img/itf_ht/hr_b_03.gif"></td></tr><tr><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td><td bgcolor="3481A6" valign="bottom" colspan="2"><img src="lib/img/itf_ht/rtl02.gif"></td></tr><tr><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td><td height="20" width="20%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Первый курс:</h3></td><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td>
<td height="20" width="20%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Второй курс:</h3></td><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td>
<td height="20" width="20%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Третий курс:</h3></td><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td>
<td height="20" width="20%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Четвертый курс:</h3></td><td width="1" valign="bottom"><img src="lib/img/itf_ht/rbl04.gif"></td>
<td height="20" width="20%" bgcolor="3481A6" align="center" background="lib/img/itf_ht/bg_h2_02.gif"><h3>Пятый курс:</h3></td></tr>
<tr><td width="1" valign="bottom"></td><td width="20%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=166"><b>(з/о)иксс-23</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=165"><b>(з/о)ПС-23к</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
<td width="1" valign="bottom"></td><td width="20%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=142"><b>(з/о)ИКСС-22к</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
<td width="1" valign="bottom"></td><td width="20%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=118"><b>(з/о)ИКСС-21к</b></a> </td></tr>
<tr><td bgcolor="dddddd" align="center"><a href="?mn=2&amp;obj=143"><b>(з/о)ПС-22к</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
<td width="1" valign="bottom"></td><td width="20%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=87"><b>(з/о)ПКС-20</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
<td width="1" valign="bottom"></td><td width="20%" bgcolor="ffffff" align="center" valign="top"><table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr><td><table border="0" cellpadding="1" cellspacing="1" width="100%" bgcolor="3481A6">
<tbody><tr><td bgcolor="eeeeee" align="center"><a href="?mn=2&amp;obj=46"><b>(з/о)ПКС-19</b></a> </td></tr>
</tbody></table></td></tr><tr><td align="right" colspan="8" background="lib/img/itf_ht/hr_b_04.gif"><img src="lib/img/itf_ht/hr_b_05.gif"></td></tr></tbody></table></td>
</tr>
</tbody></table><br>
</body></html>`

View File

@@ -1,64 +0,0 @@
import { Day } from '@/shared/model/day'
import { parsePage } from '@/app/parser/schedule'
import contentTypeParser from 'content-type'
import { JSDOM } from 'jsdom'
// import { content as mockContent } from './mock'
import { reportParserError } from '@/app/logger'
// import { groups } from '@/shared/data/groups'
// const fetchingGroups: {
// [groupID: number]: boolean
// } = Object.fromEntries(Object.values(groups).map(([gId]) => [gId, false]))
// const callbacks: {
// [groupID: number]: Set<{ resolve: (days: Day[]) => void, reject: (e: unknown) => void }>
// } = Object.fromEntries(Object.values(groups).map(([gId]) => [gId, new Set()]))
export async function getSchedule(groupID: number, groupName: string): Promise<Day[]> {
// if (fetchingGroups[groupID]) {
// return new Promise((resolve, reject) => {
// callbacks[groupID].add({
// resolve: (days: Day[]) => resolve(days),
// reject
// })
// })
// } else {
// fetchingGroups[groupID] = true
// }
// try {
// const result = await parseSchedule(groupID, groupName)
// fetchingGroups[groupID] = false
// Array.from(callbacks[groupID].values()).forEach(({ resolve }) => resolve(result))
// callbacks[groupID].clear()
// return result
// } catch(e) {
// fetchingGroups[groupID] = false
// console.log(Array.from(callbacks[groupID].values()).length)
// Array.from(callbacks[groupID].values()).forEach(({ reject }) => reject(e))
// callbacks[groupID].clear()
// throw e
// }
}
export async function parseSchedule(groupID: number, groupName: string) {
const page = await fetch(`${process.env.PROXY_URL ?? 'https://lk.ks.psuti.ru'}/?mn=2&obj=${groupID}`)
// const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } }
const content = await page.text()
const contentType = page.headers.get('content-type')
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
try {
const root = new JSDOM(content).window.document
return parsePage(root, groupName)
} catch (e) {
console.error('Error while parsing lk.ks.psuti.ru')
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
throw e
}
} else {
console.error(page.status, contentType)
console.error(content.length > 500 ? content.slice(0, 500 - 3) + '...' : content)
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
throw new Error('Error while fetching lk.ks.psuti.ru')
}
}

View File

@@ -2,7 +2,6 @@ import { Day } from '@/shared/model/day'
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
import contentTypeParser from 'content-type'
import { JSDOM } from 'jsdom'
// import { content as mockContent } from './mock'
import { reportParserError } from '@/app/logger'
import { PROXY_URL } from '@/shared/constants/urls'
@@ -12,41 +11,62 @@ export type ScheduleResult = {
availableWeeks?: WeekInfo[]
}
// ПС-7: 146
export async function getSchedule(groupID: number, groupName: string, wk?: number, parseWeekNavigation: boolean = true): Promise<ScheduleResult> {
// Валидация параметров
if (!Number.isInteger(groupID) || groupID <= 0) {
throw new Error('Invalid groupID: must be a positive integer')
}
if (wk !== undefined && (!Number.isInteger(wk) || wk <= 0 || !isFinite(wk))) {
throw new Error('Invalid wk parameter: must be a positive integer')
}
const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}`
const page = await fetch(url)
// const page = { text: async () => mockContent, status: 200, headers: { get: (s: string) => s && 'text/html' } }
const content = await page.text()
const contentType = page.headers.get('content-type')
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
let dom: JSDOM | null = null
try {
dom = new JSDOM(content, { url })
const root = dom.window.document
const result = parsePage(root, groupName, url, parseWeekNavigation)
const scheduleResult = {
days: result.days,
currentWk: result.currentWk || wk,
availableWeeks: result.availableWeeks
}
// Явно очищаем JSDOM для освобождения памяти
dom.window.close()
dom = null
return scheduleResult
} catch(e) {
// Очищаем JSDOM даже в случае ошибки
if (dom) {
// Добавляем таймаут 30 секунд для fetch запроса
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 секунд
try {
const page = await fetch(url, { signal: controller.signal })
clearTimeout(timeoutId)
const content = await page.text()
const contentType = page.headers.get('content-type')
if (page.status === 200 && contentType && contentTypeParser.parse(contentType).type === 'text/html') {
let dom: JSDOM | null = null
try {
dom = new JSDOM(content, { url })
const root = dom.window.document
const result = parsePage(root, groupName, url, parseWeekNavigation)
const scheduleResult = {
days: result.days,
currentWk: result.currentWk || wk,
availableWeeks: result.availableWeeks
}
// Явно очищаем JSDOM для освобождения памяти
dom.window.close()
dom = null
return scheduleResult
} catch(e) {
// Очищаем JSDOM даже в случае ошибки
if (dom) {
dom.window.close()
}
console.error(`Error while parsing ${PROXY_URL}`)
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
throw e
}
console.error(`Error while parsing ${PROXY_URL}`)
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для группы', groupName)
throw e
} else {
// Логируем только метаданные, без содержимого ответа
console.error(`Failed to fetch schedule: status=${page.status}, contentType=${contentType}, contentLength=${content.length}`)
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
throw new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`)
}
} else {
console.error(page.status, contentType)
console.error(content.length > 500 ? content.slice(0, 500 - 3) + '...' : content)
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для группы', groupName)
throw new Error(`Error while fetching ${PROXY_URL}`)
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timeout while fetching ${PROXY_URL}`)
}
throw error
}
}

View File

@@ -114,7 +114,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
const settings = loadSettings()
const group = context.params?.group
const wkParam = context.query.wk
const wk = wkParam ? Number(wkParam) : undefined
// Валидация wk параметра: проверка на валидное число (не NaN, не Infinity)
const wk = wkParam && !isNaN(Number(wkParam)) && isFinite(Number(wkParam)) && Number.isInteger(Number(wkParam)) && Number(wkParam) > 0
? Number(wkParam)
: undefined
if (group && Object.hasOwn(groups, group) && group in groups) {
let scheduleResult: ScheduleResult

View File

@@ -1,8 +1,7 @@
import '@/shared/styles/globals.css'
import type { AppProps } from 'next/app'
import { ThemeProvider } from '@/shared/providers/theme-provider'
import { LoadingContextProvider, LoadingContext } from '@/shared/context/loading-context'
import { LoadingOverlay } from '@/shared/ui/loading-overlay'
import { LoadingContextProvider, LoadingContext, LoadingOverlay } from '@/shared/context/loading-context'
import Head from 'next/head'
import React from 'react'

View File

@@ -15,6 +15,7 @@ import {
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select'
import { ToastContainer, Toast } from '@/shared/ui/toast'
import Head from 'next/head'
type AdminPageProps = {
@@ -34,6 +35,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
const [showEditDialog, setShowEditDialog] = React.useState(false)
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
const [groupToDelete, setGroupToDelete] = React.useState<string | null>(null)
const [toasts, setToasts] = React.useState<Toast[]>([])
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
const id = Date.now().toString()
setToasts((prev) => [...prev, { id, message, type }])
}
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}
// Форма добавления/редактирования
const [formData, setFormData] = React.useState({
@@ -131,15 +142,20 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
if (res.ok && data.success) {
// Обновляем состояние из ответа сервера (для синхронизации)
setSettings(data.settings)
showToast('Настройки успешно обновлены', 'success')
} else {
// Откатываем изменения при ошибке
setSettings(previousSettings)
setError(data.error || 'Ошибка при обновлении настроек')
const errorMessage = data.error || 'Ошибка при обновлении настроек'
setError(errorMessage)
showToast(errorMessage, 'error')
}
} catch (err) {
// Откатываем изменения при ошибке
setSettings(previousSettings)
setError('Ошибка соединения с сервером')
const errorMessage = 'Ошибка соединения с сервером'
setError(errorMessage)
showToast(errorMessage, 'error')
}
}
@@ -166,11 +182,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
setGroups(data.groups)
setShowAddDialog(false)
setFormData({ id: '', parseId: '', name: '', course: '1' })
showToast('Группа успешно добавлена', 'success')
} else {
setError(data.error || 'Ошибка при добавлении группы')
const errorMessage = data.error || 'Ошибка при добавлении группы'
setError(errorMessage)
showToast(errorMessage, 'error')
}
} catch (err) {
setError('Ошибка соединения с сервером')
const errorMessage = 'Ошибка соединения с сервером'
setError(errorMessage)
showToast(errorMessage, 'error')
} finally {
setLoading(false)
}
@@ -201,11 +222,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
setShowEditDialog(false)
setEditingGroup(null)
setFormData({ id: '', parseId: '', name: '', course: '1' })
showToast('Группа успешно обновлена', 'success')
} else {
setError(data.error || 'Ошибка при редактировании группы')
const errorMessage = data.error || 'Ошибка при редактировании группы'
setError(errorMessage)
showToast(errorMessage, 'error')
}
} catch (err) {
setError('Ошибка соединения с сервером')
const errorMessage = 'Ошибка соединения с сервером'
setError(errorMessage)
showToast(errorMessage, 'error')
} finally {
setLoading(false)
}
@@ -228,11 +254,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
setGroups(data.groups)
setShowDeleteDialog(false)
setGroupToDelete(null)
showToast('Группа успешно удалена', 'success')
} else {
setError(data.error || 'Ошибка при удалении группы')
const errorMessage = data.error || 'Ошибка при удалении группы'
setError(errorMessage)
showToast(errorMessage, 'error')
}
} catch (err) {
setError('Ошибка соединения с сервером')
const errorMessage = 'Ошибка соединения с сервером'
setError(errorMessage)
showToast(errorMessage, 'error')
} finally {
setLoading(false)
}
@@ -586,6 +617,9 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
</DialogFooter>
</DialogContent>
</Dialog>
{/* Toast уведомления */}
<ToastContainer toasts={toasts} onClose={removeToast} />
</>
)
}

View File

@@ -21,3 +21,4 @@ export default function handler(

View File

@@ -6,6 +6,79 @@ type ResponseData = {
error?: string
}
// Rate limiting: 5 попыток в 15 минут
const MAX_ATTEMPTS = 5
const WINDOW_MS = 15 * 60 * 1000 // 15 минут
interface RateLimitEntry {
attempts: number
resetTime: number
}
const rateLimitMap = new Map<string, RateLimitEntry>()
function getClientIP(req: NextApiRequest): string {
// Получаем IP адрес клиента
const forwarded = req.headers['x-forwarded-for']
const realIP = req.headers['x-real-ip']
const remoteAddress = req.socket.remoteAddress
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim()
}
if (typeof realIP === 'string') {
return realIP
}
if (remoteAddress) {
return remoteAddress
}
return 'unknown'
}
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const entry = rateLimitMap.get(ip)
// Очищаем старые записи
if (entry && now > entry.resetTime) {
rateLimitMap.delete(ip)
}
const currentEntry = rateLimitMap.get(ip)
if (!currentEntry) {
// Первая попытка
rateLimitMap.set(ip, {
attempts: 1,
resetTime: now + WINDOW_MS
})
return true
}
if (currentEntry.attempts >= MAX_ATTEMPTS) {
return false
}
// Увеличиваем счетчик попыток
currentEntry.attempts++
return true
}
function recordFailedAttempt(ip: string): void {
const entry = rateLimitMap.get(ip)
if (entry) {
// Попытка уже засчитана в checkRateLimit
return
}
// Если записи нет, создаем новую
const now = Date.now()
rateLimitMap.set(ip, {
attempts: 1,
resetTime: now + WINDOW_MS
})
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
@@ -15,6 +88,19 @@ export default function handler(
return
}
const clientIP = getClientIP(req)
// Проверяем rate limit
if (!checkRateLimit(clientIP)) {
const entry = rateLimitMap.get(clientIP)
const retryAfter = entry ? Math.ceil((entry.resetTime - Date.now()) / 1000) : WINDOW_MS / 1000
res.status(429).json({
error: 'Too many login attempts. Please try again later.',
retryAfter
})
return
}
const { password } = req.body
if (!password || typeof password !== 'string') {
@@ -23,9 +109,13 @@ export default function handler(
}
if (verifyPassword(password)) {
// Успешный вход - сбрасываем rate limit
rateLimitMap.delete(clientIP)
setSessionCookie(res)
res.status(200).json({ success: true })
} else {
// Неудачная попытка - записываем
recordFailedAttempt(clientIP)
res.status(401).json({ error: 'Invalid password' })
}
}
@@ -33,3 +123,4 @@ export default function handler(

View File

@@ -21,3 +21,4 @@ export default function handler(

View File

@@ -1,5 +1,7 @@
import React from 'react'
import { useRouter } from 'next/router'
import { Spinner } from '@/shared/ui/spinner'
import { cn } from '@/shared/utils'
interface LoadingContextType {
isLoading: boolean
@@ -44,3 +46,82 @@ export function LoadingContextProvider({ children }: React.PropsWithChildren) {
)
}
const loadingMessages = [
'Вайбкодим…',
'Отменяем пары…',
'Объезжаем пробки…',
'Ищем замены…',
'Ждем выходных…',
]
interface LoadingOverlayProps {
isLoading: boolean
}
export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
const [currentMessage, setCurrentMessage] = React.useState<string>('')
const [messageOpacity, setMessageOpacity] = React.useState(0)
React.useEffect(() => {
if (!isLoading) {
setCurrentMessage('')
setMessageOpacity(0)
return
}
// Выбираем случайное сообщение при старте загрузки
const getRandomMessage = () => {
const randomIndex = Math.floor(Math.random() * loadingMessages.length)
return loadingMessages[randomIndex]
}
// Устанавливаем первое сообщение
setCurrentMessage(getRandomMessage())
setMessageOpacity(1)
// Меняем сообщение каждые 2 секунды
const interval = setInterval(() => {
// Fade out
setMessageOpacity(0)
// После fade out меняем сообщение и fade in
setTimeout(() => {
setCurrentMessage(getRandomMessage())
setMessageOpacity(1)
}, 300) // Длительность fade анимации
}, 2000)
return () => {
clearInterval(interval)
}
}, [isLoading])
return (
<div
className={cn(
'fixed inset-0 z-50 flex items-center justify-center',
'bg-background/80 backdrop-blur-md',
'transition-opacity duration-300',
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
aria-label="Загрузка"
role="status"
aria-hidden={!isLoading}
>
{isLoading && (
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16">
<Spinner size="large" />
</div>
<div
className="min-h-[1.5rem] text-center transition-opacity duration-300"
style={{ opacity: messageOpacity }}
>
{currentMessage}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,32 +0,0 @@
import React from 'react'
import { Spinner } from './spinner'
import { cn } from '@/shared/utils'
interface LoadingOverlayProps {
isLoading: boolean
}
export function LoadingOverlay({ isLoading }: LoadingOverlayProps) {
return (
<div
className={cn(
'fixed inset-0 z-50 flex items-center justify-center',
'bg-background/80 backdrop-blur-md',
'transition-opacity duration-300',
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
aria-label="Загрузка"
role="status"
aria-hidden={!isLoading}
>
{isLoading && (
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16">
<Spinner size="large" />
</div>
</div>
)}
</div>
)
}

86
src/shared/ui/toast.tsx Normal file
View File

@@ -0,0 +1,86 @@
import React from 'react'
import { cn } from '@/shared/utils'
import { CheckCircle2, XCircle, X } from 'lucide-react'
export type ToastType = 'success' | 'error'
export interface Toast {
id: string
message: string
type: ToastType
}
interface ToastProps {
toast: Toast
onClose: () => void
}
export function ToastComponent({ toast, onClose }: ToastProps) {
const [isClosing, setIsClosing] = React.useState(false)
React.useEffect(() => {
const timer = setTimeout(() => {
handleClose()
}, 3000) // Автоматически закрывается через 3 секунды
return () => clearTimeout(timer)
}, [])
const handleClose = () => {
setIsClosing(true)
// Ждем завершения анимации перед удалением из DOM
setTimeout(() => {
onClose()
}, 300) // Длительность анимации исчезновения
}
return (
<div
className={cn(
'fixed bottom-4 left-1/2 -translate-x-1/2 z-50',
'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg',
'border min-w-[300px] max-w-[500px]',
'transition-all duration-300 ease-in-out',
isClosing
? 'opacity-0 translate-y-2 scale-95'
: 'opacity-100 translate-y-0 scale-100 animate-in slide-in-from-bottom-5 fade-in-0',
toast.type === 'success'
? 'bg-background border-green-500/50 text-foreground'
: 'bg-background border-destructive/50 text-foreground'
)}
role="alert"
>
{toast.type === 'success' ? (
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" />
) : (
<XCircle className="h-5 w-5 text-destructive flex-shrink-0" />
)}
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button
onClick={handleClose}
className="flex-shrink-0 rounded-sm opacity-70 hover:opacity-100 transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 min-w-[24px] min-h-[24px] flex items-center justify-center"
aria-label="Закрыть уведомление"
>
<X className="h-4 w-4" />
</button>
</div>
)
}
interface ToastContainerProps {
toasts: Toast[]
onClose: (id: string) => void
}
export function ToastContainer({ toasts, onClose }: ToastContainerProps) {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2 items-center">
{toasts.map((toast) => (
<ToastComponent key={toast.id} toast={toast} onClose={() => onClose(toast.id)} />
))}
</div>
)
}

View File

@@ -2,11 +2,28 @@ import { NextApiRequest, NextApiResponse } from 'next'
import crypto from 'crypto'
const SESSION_COOKIE_NAME = 'admin_session'
const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET || 'change-me-in-production'
const SESSION_SECRET = process.env.ADMIN_SESSION_SECRET
const SESSION_DURATION = 1000 * 60 * 60 * 24 // 24 часа
// Получаем секрет сессии с учетом окружения
function getSessionSecret(): string {
if (SESSION_SECRET) {
return SESSION_SECRET
}
// В production требуем явную установку
if (process.env.NODE_ENV === 'production') {
throw new Error('ADMIN_SESSION_SECRET must be set in production environment')
}
// В development используем дефолтный секрет с предупреждением
console.warn('ADMIN_SESSION_SECRET is not set. Using default secret for development. This is not secure for production!')
return 'change-me-in-production'
}
/**
* Проверяет пароль администратора
* Использует timing-safe сравнение для защиты от timing attacks
*/
export function verifyPassword(password: string): boolean {
const adminPassword = process.env.ADMIN_PASSWORD
@@ -14,17 +31,31 @@ export function verifyPassword(password: string): boolean {
console.error('ADMIN_PASSWORD is not set')
return false
}
return password === adminPassword
// Используем timing-safe сравнение для защиты от timing attacks
if (password.length !== adminPassword.length) {
return false
}
try {
const passwordBuffer = Buffer.from(password, 'utf8')
const adminPasswordBuffer = Buffer.from(adminPassword, 'utf8')
return crypto.timingSafeEqual(passwordBuffer, adminPasswordBuffer)
} catch {
return false
}
}
/**
* Создает сессионный токен
*/
function createSessionToken(): string {
const secret = getSessionSecret()
const randomBytes = crypto.randomBytes(32).toString('hex')
const timestamp = Date.now().toString()
const data = `${randomBytes}:${timestamp}`
const hash = crypto.createHmac('sha256', SESSION_SECRET).update(data).digest('hex')
const hash = crypto.createHmac('sha256', secret).update(data).digest('hex')
return `${data}:${hash}`
}
@@ -33,12 +64,14 @@ function createSessionToken(): string {
*/
function verifySessionToken(token: string): boolean {
try {
const secret = getSessionSecret()
const parts = token.split(':')
if (parts.length !== 3) return false
const [randomBytes, timestamp, hash] = parts
const data = `${randomBytes}:${timestamp}`
const expectedHash = crypto.createHmac('sha256', SESSION_SECRET).update(data).digest('hex')
const expectedHash = crypto.createHmac('sha256', secret).update(data).digest('hex')
if (hash !== expectedHash) return false
@@ -70,8 +103,14 @@ export function checkAuth(req: NextApiRequest): boolean {
const cookieHeader = req.headers.cookie
if (!cookieHeader) return false
// Улучшенный парсинг cookies для корректной обработки значений с '='
const cookies = cookieHeader.split(';').reduce((acc, cookie) => {
const [key, value] = cookie.trim().split('=')
const trimmed = cookie.trim()
const equalIndex = trimmed.indexOf('=')
if (equalIndex === -1) return acc
const key = trimmed.substring(0, equalIndex)
const value = trimmed.substring(equalIndex + 1)
acc[key] = value
return acc
}, {} as Record<string, string>)