fix(security): update dependencies to avoid RCE and other exploits
Обновлены зависимости Node.js, которые были уязвимы с разной степенью критичности. Обновлен Next.js, так как его предыдущая используемая версия привнесла в production-среду постоянную борьбу с майнерами. К сожалению, в этом коммите парсер расписания сломан.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,7 @@ next-env.d.ts
|
|||||||
|
|
||||||
# error logs
|
# error logs
|
||||||
error.log
|
error.log
|
||||||
|
app.log
|
||||||
|
|
||||||
# database files
|
# database files
|
||||||
db/
|
db/
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ Reskin of https://lk.ks.psuti.ru/ since it lacks mobile support.
|
|||||||
|
|
||||||
## Tech stack & features
|
## Tech stack & features
|
||||||
|
|
||||||
- React 19.2.0 with Next.js 16.0.3 (pages router)
|
- React 19.2.0 with Next.js 16.1.6 (pages router)
|
||||||
- Tailwind CSS
|
- Tailwind CSS
|
||||||
- @shadcn/ui components (built with Radix UI)
|
- @shadcn/ui components (built with Radix UI)
|
||||||
- JSDOM for parsing scraped pages, rehydration strategy for cache
|
- JSDOM for parsing scraped pages, rehydration strategy for cache
|
||||||
- TypeScript 5.9.3 with types for each package
|
- TypeScript 5.9.3 with types for each package
|
||||||
- SQLite database (better-sqlite3) for storing groups and settings
|
- SQLite database (better-sqlite3) for storing groups and settings
|
||||||
- bcrypt for secure password hashing
|
- bcrypt for secure password hashing
|
||||||
- Telegram Bot API (via [node-telegram-bot-api]) for parsing failure notifications
|
- Telegram Bot API (native `fetch`) for parsing failure notifications
|
||||||
- Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c)
|
- Custom [js parser for teachers' photos](https://gist.github.com/VityaSchel/28f1a360ee7798511765910b39c6086c)
|
||||||
- Accessibility & tab navigation support
|
- Accessibility & tab navigation support
|
||||||
- Dark theme with automatic switching based on system settings
|
- Dark theme with automatic switching based on system settings
|
||||||
|
|||||||
1708
package-lock.json
generated
1708
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -7,7 +7,7 @@
|
|||||||
"npm": ">=10.0.0"
|
"npm": ">=10.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack -H 0.0.0.0",
|
"dev": "node --disable-warning=DEP0169 ./node_modules/next/dist/bin/next dev --webpack -H 0.0.0.0",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start -H 0.0.0.0",
|
"start": "next start -H 0.0.0.0",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
@@ -30,11 +30,10 @@
|
|||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "16.0.3",
|
"next": "16.1.6",
|
||||||
"next-sitemap": "^4.2.3",
|
"next-sitemap": "^4.2.3",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"node-html-parser": "^6.1.10",
|
"node-html-parser": "^6.1.10",
|
||||||
"node-telegram-bot-api": "^0.63.0",
|
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
@@ -50,16 +49,20 @@
|
|||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/jsdom": "^21.1.3",
|
"@types/jsdom": "^21.1.3",
|
||||||
"@types/node": "22.0.0",
|
"@types/node": "22.0.0",
|
||||||
"@types/node-telegram-bot-api": "^0.61.8",
|
|
||||||
"@types/react": "19.2.0",
|
"@types/react": "19.2.0",
|
||||||
"@types/react-dom": "19.2.0",
|
"@types/react-dom": "19.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
"baseline-browser-mapping": "^2.8.32",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"eslint": "8.57.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.1.6",
|
||||||
"postcss": "8.4.47",
|
"postcss": "8.4.47",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.18",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"form-data": ">=2.5.4",
|
||||||
|
"qs": ">=6.14.1",
|
||||||
|
"tough-cookie": ">=4.1.3",
|
||||||
|
"tar": ">=7.5.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Day } from '@/shared/model/day'
|
|||||||
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
|
import { parsePage, ParseResult, WeekInfo } from '@/app/parser/schedule'
|
||||||
import contentTypeParser from 'content-type'
|
import contentTypeParser from 'content-type'
|
||||||
import { JSDOM } from 'jsdom'
|
import { JSDOM } from 'jsdom'
|
||||||
import { reportParserError, logErrorToFile } from '@/app/logger'
|
import { reportParserError, logErrorToFile, logInfo } from '@/app/logger'
|
||||||
import { PROXY_URL } from '@/shared/constants/urls'
|
import { PROXY_URL } from '@/shared/constants/urls'
|
||||||
|
|
||||||
export type ScheduleResult = {
|
export type ScheduleResult = {
|
||||||
@@ -29,6 +29,7 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}`
|
const url = `${PROXY_URL}/?mn=2&obj=${groupID}${wk ? `&wk=${wk}` : ''}`
|
||||||
|
logInfo('Schedule fetch start', { groupID, groupName, wk: wk ?? 'current' })
|
||||||
|
|
||||||
// Добавляем таймаут 8 секунд для fetch запроса
|
// Добавляем таймаут 8 секунд для fetch запроса
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -50,6 +51,7 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe
|
|||||||
currentWk: result.currentWk || wk,
|
currentWk: result.currentWk || wk,
|
||||||
availableWeeks: result.availableWeeks
|
availableWeeks: result.availableWeeks
|
||||||
}
|
}
|
||||||
|
logInfo('Schedule fetch success', { groupName, daysCount: result.days.length, currentWk: result.currentWk })
|
||||||
// Явно очищаем JSDOM для освобождения памяти
|
// Явно очищаем JSDOM для освобождения памяти
|
||||||
dom.window.close()
|
dom.window.close()
|
||||||
dom = null
|
dom = null
|
||||||
@@ -177,6 +179,7 @@ export async function getTeacherSchedule(teacherID: number, teacherName: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = `${PROXY_URL}/?mn=3&obj=${teacherID}${wk ? `&wk=${wk}` : ''}`
|
const url = `${PROXY_URL}/?mn=3&obj=${teacherID}${wk ? `&wk=${wk}` : ''}`
|
||||||
|
logInfo('Teacher schedule fetch start', { teacherID, teacherName, wk: wk ?? 'current' })
|
||||||
|
|
||||||
// Добавляем таймаут 8 секунд для fetch запроса
|
// Добавляем таймаут 8 секунд для fetch запроса
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -198,6 +201,7 @@ export async function getTeacherSchedule(teacherID: number, teacherName: string,
|
|||||||
currentWk: result.currentWk || wk,
|
currentWk: result.currentWk || wk,
|
||||||
availableWeeks: result.availableWeeks
|
availableWeeks: result.availableWeeks
|
||||||
}
|
}
|
||||||
|
logInfo('Teacher schedule fetch success', { teacherName, daysCount: result.days.length, currentWk: result.currentWk })
|
||||||
// Явно очищаем JSDOM для освобождения памяти
|
// Явно очищаем JSDOM для освобождения памяти
|
||||||
dom.window.close()
|
dom.window.close()
|
||||||
dom = null
|
dom = null
|
||||||
|
|||||||
@@ -1,22 +1,75 @@
|
|||||||
import TelegramBot from 'node-telegram-bot-api'
|
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
const token = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN
|
const token = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_BOTAPI_TOKEN
|
||||||
const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID
|
const ownerID = process.env.PARSING_FAILURE_NOTIFICATIONS_TELEGRAM_CHAT_ID
|
||||||
|
|
||||||
let bot: TelegramBot
|
|
||||||
if (!token || !ownerID) {
|
if (!token || !ownerID) {
|
||||||
console.warn('Telegram Token is not specified. This means you won\'t get any notifications about parsing failures.')
|
console.warn('Telegram Token is not specified. This means you won\'t get any notifications about parsing failures.')
|
||||||
} else {
|
|
||||||
bot = new TelegramBot(token, { polling: false })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Путь к файлу логов (в корне проекта)
|
async function sendTelegramMessage(text: string): Promise<void> {
|
||||||
const getErrorLogPath = () => {
|
if (!token || !ownerID) return
|
||||||
// В production (standalone) используем текущую рабочую директорию
|
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
|
||||||
// В development используем корень проекта (process.cwd())
|
const res = await fetch(url, {
|
||||||
return path.join(process.cwd(), 'error.log')
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ chat_id: ownerID, text }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Telegram sendMessage failed:', res.status, await res.text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уровни логов: debug < info < warn < error
|
||||||
|
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const
|
||||||
|
type LogLevel = keyof typeof LOG_LEVELS
|
||||||
|
|
||||||
|
const currentLevel = ((): number => {
|
||||||
|
const env = process.env.LOG_LEVEL?.toLowerCase()
|
||||||
|
if (env && env in LOG_LEVELS) return LOG_LEVELS[env as LogLevel]
|
||||||
|
return process.env.NODE_ENV === 'development' ? LOG_LEVELS.debug : LOG_LEVELS.info
|
||||||
|
})()
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
|
|
||||||
|
// Путь к файлам логов (в корне проекта)
|
||||||
|
const getErrorLogPath = () => path.join(process.cwd(), 'error.log')
|
||||||
|
const getAppLogPath = () => path.join(process.cwd(), 'app.log')
|
||||||
|
|
||||||
|
function writeAppLog(level: LogLevel, message: string, data?: Record<string, unknown>): void {
|
||||||
|
if (LOG_LEVELS[level] < currentLevel) return
|
||||||
|
try {
|
||||||
|
const logPath = getAppLogPath()
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
let line = `[${timestamp}] ${level.toUpperCase()}: ${message}`
|
||||||
|
if (data != null && Object.keys(data).length > 0) {
|
||||||
|
line += ' ' + JSON.stringify(data)
|
||||||
|
}
|
||||||
|
line += '\n'
|
||||||
|
fs.appendFileSync(logPath, line, 'utf8')
|
||||||
|
} catch {
|
||||||
|
// не падаем из-за логгера
|
||||||
|
}
|
||||||
|
if (isDev && (level === 'debug' || level === 'info')) {
|
||||||
|
const out = level === 'debug' ? console.debug : console.info
|
||||||
|
out(`[${level}]`, message, data ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Логирование отладочных сообщений (пишется при LOG_LEVEL=debug или в development) */
|
||||||
|
export function logDebug(message: string, data?: Record<string, unknown>): void {
|
||||||
|
writeAppLog('debug', message, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Информационное логирование (пишется при LOG_LEVEL=info и выше, по умолчанию в production) */
|
||||||
|
export function logInfo(message: string, data?: Record<string, unknown>): void {
|
||||||
|
writeAppLog('info', message, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Предупреждения (всегда пишется в app.log при уровне warn и выше) */
|
||||||
|
export function logWarn(message: string, data?: Record<string, unknown>): void {
|
||||||
|
writeAppLog('warn', message, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +106,5 @@ export function logErrorToFile(error: Error | string, context?: Record<string, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function reportParserError(...text: string[]) {
|
export async function reportParserError(...text: string[]) {
|
||||||
if (!token || !ownerID) return
|
await sendTelegramMessage(text.join(' '))
|
||||||
|
|
||||||
await bot.sendMessage(ownerID, text.join(' '))
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Day } from '@/shared/model/day'
|
import { Day } from '@/shared/model/day'
|
||||||
import { Lesson } from '@/shared/model/lesson'
|
import { Lesson } from '@/shared/model/lesson'
|
||||||
|
import { logDebug } from '@/app/logger'
|
||||||
|
|
||||||
export type WeekInfo = {
|
export type WeekInfo = {
|
||||||
wk: number
|
wk: number
|
||||||
@@ -770,19 +771,18 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!table) {
|
if (!table) {
|
||||||
// Логируем информацию о найденных таблицах для отладки
|
logDebug('parsePage: tables analyzing', { groupName, tablesCount: tables.length })
|
||||||
console.log(`[parsePage] Found ${tables.length} tables, analyzing...`)
|
|
||||||
tables.forEach((t, i) => {
|
tables.forEach((t, i) => {
|
||||||
const text = t.textContent?.substring(0, 200) || ''
|
const text = t.textContent?.substring(0, 200) || ''
|
||||||
const hasDayTitles = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}/i.test(text)
|
const hasDayTitles = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}/i.test(text)
|
||||||
const hasTimeSlots = /\d{1,2}:\d{2}\s*–\s*\d{1,2}:\d{2}/.test(text)
|
const hasTimeSlots = /\d{1,2}:\d{2}\s*–\s*\d{1,2}:\d{2}/.test(text)
|
||||||
const nameCount = (text.match(/[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+/g) || []).length
|
const nameCount = (text.match(/[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+/g) || []).length
|
||||||
console.log(`[parsePage] Table ${i}: rows=${t.querySelectorAll('tr').length}, hasDayTitles=${hasDayTitles}, hasTimeSlots=${hasTimeSlots}, nameCount=${nameCount}, preview="${text}"`)
|
logDebug('parsePage: table analysis', { tableIndex: i, rows: t.querySelectorAll('tr').length, hasDayTitles, hasTimeSlots, nameCount, preview: text.substring(0, 80) })
|
||||||
})
|
})
|
||||||
throw new Error(`Table not found for ${groupName}. Found ${tables.length} tables on the page.`)
|
throw new Error(`Table not found for ${groupName}. Found ${tables.length} tables on the page.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[parsePage] Selected table with ${table.querySelectorAll('tr').length} rows`)
|
logDebug('parsePage: selected table', { groupName, rows: table.querySelectorAll('tr').length })
|
||||||
|
|
||||||
// Пытаемся найти tbody или использовать прямые children таблицы
|
// Пытаемся найти tbody или использовать прямые children таблицы
|
||||||
let tbody: HTMLTableSectionElement | null = null
|
let tbody: HTMLTableSectionElement | null = null
|
||||||
@@ -799,15 +799,19 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем строки из tbody или напрямую из таблицы
|
// Структура таблицы расписания с lk.ks.psuti.ru (mn=2&obj=ID группы):
|
||||||
|
// allRows[0] — название группы в одной ячейке (colspan=7);
|
||||||
|
// allRows[1] — пустая строка-разделитель (одна td colspan=7);
|
||||||
|
// далее повторяются блоки: [заголовок дня] [заголовок колонок] [пары...] [пустая строка].
|
||||||
|
// Заголовок дня: одна <tr> с одной <td colspan=7>, внутри вложенная таблица с <h3>Понедельник DD.MM.YYYY / N неделя</h3>.
|
||||||
|
// Заголовок колонок: <tr> с 7 <td> — «№ пары», «Время занятий», «Способ», «Дисциплина, преподаватель», «Тема занятия», «Ресурс», «Задание для выполнения».
|
||||||
|
// Строка пары: 7 <td> — номер, время (08:00 – 09:30), способ, ячейка с предметом/преподавателем/местом (subject + <br> + teacher + <font> адрес, Кабинет), тема, ресурсы, задание.
|
||||||
const allRows = tbody
|
const allRows = tbody
|
||||||
? Array.from(tbody.querySelectorAll('tr'))
|
? Array.from(tbody.querySelectorAll('tr'))
|
||||||
: Array.from(table.querySelectorAll('tr'))
|
: Array.from(table.querySelectorAll('tr'))
|
||||||
|
|
||||||
const rows = allRows.slice(2)
|
const rows = allRows.slice(2)
|
||||||
|
logDebug('parsePage: rows to parse', { groupName, rowsCount: rows.length, firstRows: rows.slice(0, 5).map(r => r.textContent?.trim().substring(0, 50)) })
|
||||||
console.log(`[parsePage] Found ${rows.length} rows to parse for ${groupName}`)
|
|
||||||
console.log(`[parsePage] First few rows text:`, rows.slice(0, 5).map(r => r.textContent?.trim().substring(0, 50)))
|
|
||||||
|
|
||||||
const days = []
|
const days = []
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@@ -827,13 +831,13 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
const rowText = row.textContent?.trim() || ''
|
const rowText = row.textContent?.trim() || ''
|
||||||
|
|
||||||
const isDivider = rowText === ''
|
const isDivider = rowText === ''
|
||||||
|
// Строка заголовка таблицы (идёт сразу после заголовка дня) — не считать новым днём
|
||||||
|
const looksLikeTableHeader = /№ пары|Время занятий|Дисциплина, преподаватель/i.test(rowText)
|
||||||
// Проверяем, является ли строка заголовком дня: должна содержать паттерн "день недели дата / номер неделя"
|
// Проверяем, является ли строка заголовком дня: должна содержать паттерн "день недели дата / номер неделя"
|
||||||
// Поддерживаем оба формата: с пробелом и без пробела перед "/"
|
|
||||||
const looksLikeDayTitle = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя/i.test(rowText)
|
const looksLikeDayTitle = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя/i.test(rowText)
|
||||||
// Заголовок дня может быть в любой момент - либо когда нет дня, либо когда начинается новый день
|
const isDayTitle = looksLikeDayTitle && !looksLikeTableHeader
|
||||||
const isDayTitle = looksLikeDayTitle
|
|
||||||
// Если уже есть день с датой и встречаем новый заголовок дня, сохраняем предыдущий день
|
// Если уже есть день с датой и встречаем новый заголовок дня, сохраняем предыдущий день
|
||||||
const isNewDayTitle = looksLikeDayTitle && ('date' in dayInfo)
|
const isNewDayTitle = isDayTitle && ('date' in dayInfo)
|
||||||
const isTableHeader = previousRowIsDayTitle
|
const isTableHeader = previousRowIsDayTitle
|
||||||
|
|
||||||
// Если встречаем новый день, сохраняем предыдущий
|
// Если встречаем новый день, сохраняем предыдущий
|
||||||
@@ -847,8 +851,9 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDivider) {
|
if (isDivider) {
|
||||||
// Сохраняем день при разделителе, только если есть данные
|
// Сохраняем день при разделителе только если есть уроки — иначе пустая строка
|
||||||
if ('date' in dayInfo) {
|
// между заголовком дня и строкой «№ пары / Время» сбрасывала контекст и все пары пропускались
|
||||||
|
if ('date' in dayInfo && dayLessons.length > 0) {
|
||||||
days.push({ ...dayInfo, lessons: dayLessons })
|
days.push({ ...dayInfo, lessons: dayLessons })
|
||||||
dayLessons = []
|
dayLessons = []
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@@ -880,7 +885,7 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { date, weekNumber } = dayTitleParser(dayTitleText)
|
const { date, weekNumber } = dayTitleParser(dayTitleText)
|
||||||
console.log(`[parsePage] Parsed day title: ${dayTitleText} -> date: ${date}, week: ${weekNumber}`)
|
logDebug('parsePage: parsed day title', { dayTitleText, date, weekNumber })
|
||||||
dayInfo.date = date
|
dayInfo.date = date
|
||||||
dayInfo.weekNumber = weekNumber
|
dayInfo.weekNumber = weekNumber
|
||||||
if (!currentWeekNumber) {
|
if (!currentWeekNumber) {
|
||||||
@@ -890,16 +895,21 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
// Важно: после парсинга заголовка дня, следующий цикл должен обрабатывать уроки
|
// Важно: после парсинга заголовка дня, следующий цикл должен обрабатывать уроки
|
||||||
// Поэтому НЕ делаем continue, а просто устанавливаем флаг
|
// Поэтому НЕ делаем continue, а просто устанавливаем флаг
|
||||||
// Проверяем, что dayInfo действительно установлен
|
// Проверяем, что dayInfo действительно установлен
|
||||||
console.log(`[parsePage] Day info set: date=${dayInfo.date}, weekNumber=${dayInfo.weekNumber}`)
|
logDebug('parsePage: day info set', { date: dayInfo.date, weekNumber: dayInfo.weekNumber })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Если не удалось распарсить заголовок, пропускаем строку
|
// Если не удалось распарсить заголовок, пропускаем строку
|
||||||
console.warn(`[parsePage] Failed to parse day title: ${dayTitleText}`, error)
|
logDebug('parsePage: failed to parse day title', { dayTitleText, error: String(error) })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Пытаемся распарсить как урок, только если уже есть день
|
// Пытаемся распарсить как урок, только если уже есть день
|
||||||
const hasDayContext = 'date' in dayInfo
|
const hasDayContext = 'date' in dayInfo
|
||||||
if (hasDayContext) {
|
if (hasDayContext) {
|
||||||
|
// Сразу пропускаем строку заголовка таблицы (№ пары, Время занятий, …)
|
||||||
|
if (looksLikeTableHeader) {
|
||||||
|
previousRowIsDayTitle = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Пропускаем строки, которые являются только номерами пар или временем (заголовки столбцов)
|
// Пропускаем строки, которые являются только номерами пар или временем (заголовки столбцов)
|
||||||
const cells = Array.from(row.querySelectorAll(':scope > td'))
|
const cells = Array.from(row.querySelectorAll(':scope > td'))
|
||||||
const cellTexts = cells.map(cell => cell.textContent?.trim() || '').filter(t => t)
|
const cellTexts = cells.map(cell => cell.textContent?.trim() || '').filter(t => t)
|
||||||
@@ -928,11 +938,11 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
} else if ('fallbackDiscipline' in lesson && lesson.fallbackDiscipline) {
|
} else if ('fallbackDiscipline' in lesson && lesson.fallbackDiscipline) {
|
||||||
lessonName = lesson.fallbackDiscipline
|
lessonName = lesson.fallbackDiscipline
|
||||||
}
|
}
|
||||||
console.log(`[parsePage] Parsed lesson: ${lessonName}`)
|
logDebug('parsePage: parsed lesson', { lessonName })
|
||||||
dayLessons.push(lesson)
|
dayLessons.push(lesson)
|
||||||
} else {
|
} else {
|
||||||
// Логируем строки, которые не распарсились как уроки
|
// Логируем строки, которые не распарсились как уроки
|
||||||
console.log(`[parsePage] Failed to parse lesson from row: ${rowText.substring(0, 100)}`)
|
logDebug('parsePage: failed to parse lesson from row', { rowPreview: rowText.substring(0, 100) })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Логируем строки, которые не распознаются как дни и не парсятся как уроки
|
// Логируем строки, которые не распознаются как дни и не парсятся как уроки
|
||||||
@@ -940,7 +950,7 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
if (rowText && !looksLikeDayTitle) {
|
if (rowText && !looksLikeDayTitle) {
|
||||||
const cells = Array.from(row.querySelectorAll(':scope > td'))
|
const cells = Array.from(row.querySelectorAll(':scope > td'))
|
||||||
if (cells.length > 0) {
|
if (cells.length > 0) {
|
||||||
console.log(`[parsePage] Skipping row (no day context): ${rowText.substring(0, 100)}`)
|
logDebug('parsePage: skipping row (no day context)', { rowPreview: rowText.substring(0, 100) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -949,11 +959,11 @@ export function parsePage(document: Document, groupName: string, url?: string, s
|
|||||||
|
|
||||||
// Добавляем последний день, если он не был добавлен
|
// Добавляем последний день, если он не был добавлен
|
||||||
if ('date' in dayInfo) {
|
if ('date' in dayInfo) {
|
||||||
console.log(`[parsePage] Adding final day with ${dayLessons.length} lessons`)
|
logDebug('parsePage: adding final day', { lessonsCount: dayLessons.length })
|
||||||
days.push({ ...dayInfo, lessons: dayLessons })
|
days.push({ ...dayInfo, lessons: dayLessons })
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[parsePage] Total days parsed: ${days.length}`)
|
logDebug('parsePage: total days parsed', { daysCount: days.length })
|
||||||
|
|
||||||
// Парсим навигацию по неделям только если включена навигация
|
// Парсим навигацию по неделям только если включена навигация
|
||||||
let availableWeeks: WeekInfo[] | undefined
|
let availableWeeks: WeekInfo[] | undefined
|
||||||
|
|||||||
@@ -364,8 +364,8 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/logs')
|
const res = await fetch('/api/admin/logs')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.success && data.logs) {
|
if (data.success) {
|
||||||
setLogs(data.logs)
|
setLogs(data.logs ?? '')
|
||||||
} else {
|
} else {
|
||||||
setLogs(data.error || 'Не удалось загрузить логи')
|
setLogs(data.error || 'Не удалось загрузить логи')
|
||||||
}
|
}
|
||||||
@@ -533,6 +533,19 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">Кнопка "Преподаватели"</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Отображать кнопку перехода к расписанию преподавателей на главной странице
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={settings.showTeachersButton ?? true}
|
||||||
|
onChange={(checked) => handleUpdateSettings({ ...settings, showTeachersButton: checked })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="p-4 border rounded-lg">
|
<div className="p-4 border rounded-lg">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -892,7 +905,7 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Логи ошибок</DialogTitle>
|
<DialogTitle>Логи ошибок</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Содержимое файла error.log
|
Ошибки парсинга записываются в error.log. Если записей пока нет — здесь будет пусто.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
@@ -14,18 +14,18 @@ async function handler(
|
|||||||
// Путь к файлу логов (в корне проекта)
|
// Путь к файлу логов (в корне проекта)
|
||||||
const logPath = path.join(process.cwd(), 'error.log')
|
const logPath = path.join(process.cwd(), 'error.log')
|
||||||
|
|
||||||
// Проверяем существование файла
|
// Проверяем существование файла (если нет — возвращаем пустую строку, UI покажет «Логи пусты»)
|
||||||
if (!fs.existsSync(logPath)) {
|
if (!fs.existsSync(logPath)) {
|
||||||
res.status(200).json({ success: true, logs: 'Файл логов пуст или не существует.' })
|
res.status(200).json({ success: true, logs: '' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Читаем файл
|
// Читаем файл
|
||||||
const logs = fs.readFileSync(logPath, 'utf8')
|
const logs = fs.readFileSync(logPath, 'utf8')
|
||||||
|
|
||||||
// Если файл пуст
|
// Если файл пуст — возвращаем пустую строку
|
||||||
if (!logs || logs.trim().length === 0) {
|
if (!logs || logs.trim().length === 0) {
|
||||||
res.status(200).json({ success: true, logs: 'Файл логов пуст.' })
|
res.status(200).json({ success: true, logs: '' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function handler(
|
|||||||
const currentSettings = loadSettings(true)
|
const currentSettings = loadSettings(true)
|
||||||
|
|
||||||
// Обновление настроек
|
// Обновление настроек
|
||||||
const { weekNavigationEnabled, showAddGroupButton, vacationModeEnabled, vacationModeContent, debug } = req.body
|
const { weekNavigationEnabled, showAddGroupButton, showTeachersButton, vacationModeEnabled, vacationModeContent, debug } = req.body
|
||||||
|
|
||||||
if (typeof weekNavigationEnabled !== 'boolean') {
|
if (typeof weekNavigationEnabled !== 'boolean') {
|
||||||
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
|
||||||
@@ -36,6 +36,11 @@ async function handler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTeachersButton !== undefined && typeof showTeachersButton !== 'boolean') {
|
||||||
|
res.status(400).json({ error: 'showTeachersButton must be a boolean' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (vacationModeEnabled !== undefined && typeof vacationModeEnabled !== 'boolean') {
|
if (vacationModeEnabled !== undefined && typeof vacationModeEnabled !== 'boolean') {
|
||||||
res.status(400).json({ error: 'vacationModeEnabled must be a boolean' })
|
res.status(400).json({ error: 'vacationModeEnabled must be a boolean' })
|
||||||
return
|
return
|
||||||
@@ -77,6 +82,7 @@ async function handler(
|
|||||||
...currentSettings,
|
...currentSettings,
|
||||||
weekNavigationEnabled,
|
weekNavigationEnabled,
|
||||||
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : (currentSettings.showAddGroupButton ?? true),
|
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : (currentSettings.showAddGroupButton ?? true),
|
||||||
|
showTeachersButton: showTeachersButton !== undefined ? showTeachersButton : (currentSettings.showTeachersButton ?? true),
|
||||||
vacationModeEnabled: vacationModeEnabled !== undefined ? vacationModeEnabled : (currentSettings.vacationModeEnabled ?? false),
|
vacationModeEnabled: vacationModeEnabled !== undefined ? vacationModeEnabled : (currentSettings.vacationModeEnabled ?? false),
|
||||||
vacationModeContent: vacationModeContent !== undefined ? vacationModeContent : (currentSettings.vacationModeContent || ''),
|
vacationModeContent: vacationModeContent !== undefined ? vacationModeContent : (currentSettings.vacationModeContent || ''),
|
||||||
...(validatedDebug !== undefined && { debug: validatedDebug })
|
...(validatedDebug !== undefined && { debug: validatedDebug })
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type NormalModeProps = {
|
|||||||
groups: GroupsData
|
groups: GroupsData
|
||||||
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
|
groupsByCourse: { [course: number]: Array<{ id: string; name: string }> }
|
||||||
showAddGroupButton: boolean
|
showAddGroupButton: boolean
|
||||||
|
showTeachersButton: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type HomePageProps = VacationModeProps | NormalModeProps
|
type HomePageProps = VacationModeProps | NormalModeProps
|
||||||
@@ -113,7 +114,7 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Обычный режим - список групп
|
// Обычный режим - список групп
|
||||||
const { groups, groupsByCourse, showAddGroupButton } = props
|
const { groups, groupsByCourse, showAddGroupButton, showTeachersButton } = props
|
||||||
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
|
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set())
|
||||||
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false)
|
||||||
|
|
||||||
@@ -235,6 +236,7 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Кнопка перехода к расписанию преподавателей */}
|
{/* Кнопка перехода к расписанию преподавателей */}
|
||||||
|
{showTeachersButton && (
|
||||||
<div
|
<div
|
||||||
className="stagger-card mt-6"
|
className="stagger-card mt-6"
|
||||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
||||||
@@ -245,6 +247,7 @@ export default function HomePage(props: HomePageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
|
||||||
{showAddGroupButton && (
|
{showAddGroupButton && (
|
||||||
@@ -349,7 +352,8 @@ export const getServerSideProps: GetServerSideProps<HomePageProps> = async () =>
|
|||||||
vacationModeEnabled: false,
|
vacationModeEnabled: false,
|
||||||
groups,
|
groups,
|
||||||
groupsByCourse,
|
groupsByCourse,
|
||||||
showAddGroupButton: settings.showAddGroupButton ?? true
|
showAddGroupButton: settings.showAddGroupButton ?? true,
|
||||||
|
showTeachersButton: settings.showTeachersButton ?? true
|
||||||
} as NormalModeProps
|
} as NormalModeProps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ export function getSettings(): AppSettings {
|
|||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: false,
|
weekNavigationEnabled: false,
|
||||||
showAddGroupButton: true,
|
showAddGroupButton: true,
|
||||||
|
showTeachersButton: true,
|
||||||
vacationModeEnabled: false,
|
vacationModeEnabled: false,
|
||||||
vacationModeContent: '',
|
vacationModeContent: '',
|
||||||
debug: {
|
debug: {
|
||||||
@@ -381,6 +382,7 @@ export function getSettings(): AppSettings {
|
|||||||
return {
|
return {
|
||||||
weekNavigationEnabled: settings.weekNavigationEnabled ?? false,
|
weekNavigationEnabled: settings.weekNavigationEnabled ?? false,
|
||||||
showAddGroupButton: settings.showAddGroupButton ?? true,
|
showAddGroupButton: settings.showAddGroupButton ?? true,
|
||||||
|
showTeachersButton: settings.showTeachersButton ?? true,
|
||||||
vacationModeEnabled: settings.vacationModeEnabled ?? false,
|
vacationModeEnabled: settings.vacationModeEnabled ?? false,
|
||||||
vacationModeContent: settings.vacationModeContent ?? '',
|
vacationModeContent: settings.vacationModeContent ?? '',
|
||||||
...settings,
|
...settings,
|
||||||
@@ -397,6 +399,7 @@ export function getSettings(): AppSettings {
|
|||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: false,
|
weekNavigationEnabled: false,
|
||||||
showAddGroupButton: true,
|
showAddGroupButton: true,
|
||||||
|
showTeachersButton: true,
|
||||||
vacationModeEnabled: false,
|
vacationModeEnabled: false,
|
||||||
vacationModeContent: '',
|
vacationModeContent: '',
|
||||||
debug: {
|
debug: {
|
||||||
@@ -416,6 +419,7 @@ export function updateSettings(settings: AppSettings): void {
|
|||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: false,
|
weekNavigationEnabled: false,
|
||||||
showAddGroupButton: true,
|
showAddGroupButton: true,
|
||||||
|
showTeachersButton: true,
|
||||||
vacationModeEnabled: false,
|
vacationModeEnabled: false,
|
||||||
vacationModeContent: '',
|
vacationModeContent: '',
|
||||||
debug: {
|
debug: {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getSettings as getSettingsFromDB, updateSettings as updateSettingsInDB
|
|||||||
export type AppSettings = {
|
export type AppSettings = {
|
||||||
weekNavigationEnabled: boolean
|
weekNavigationEnabled: boolean
|
||||||
showAddGroupButton: boolean
|
showAddGroupButton: boolean
|
||||||
|
/** Показывать кнопку «Расписание преподавателей» на главной */
|
||||||
|
showTeachersButton?: boolean
|
||||||
vacationModeEnabled?: boolean
|
vacationModeEnabled?: boolean
|
||||||
vacationModeContent?: string
|
vacationModeContent?: string
|
||||||
debug?: {
|
debug?: {
|
||||||
@@ -40,6 +42,7 @@ export function loadSettings(forceRefresh: boolean = false): AppSettings {
|
|||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
weekNavigationEnabled: false,
|
weekNavigationEnabled: false,
|
||||||
showAddGroupButton: true,
|
showAddGroupButton: true,
|
||||||
|
showTeachersButton: true,
|
||||||
vacationModeEnabled: false,
|
vacationModeEnabled: false,
|
||||||
vacationModeContent: '',
|
vacationModeContent: '',
|
||||||
debug: {
|
debug: {
|
||||||
|
|||||||
Reference in New Issue
Block a user