feat: schedule of teachers (but one)

i think its many poop code and schedule currently now working properly
This commit is contained in:
kilyabin
2026-01-28 14:29:19 +04:00
parent 56a48b4552
commit a930dcfa4e
13 changed files with 1494 additions and 68 deletions

View File

@@ -136,3 +136,122 @@ export async function getSchedule(groupID: number, groupName: string, wk?: numbe
throw error
}
}
export async function getTeacherSchedule(teacherID: number, teacherName: string, wk?: number, parseWeekNavigation: boolean = true): Promise<ScheduleResult> {
// Валидация параметров
if (!Number.isInteger(teacherID) || teacherID <= 0) {
throw new Error('Invalid teacherID: 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=3&obj=${teacherID}${wk ? `&wk=${wk}` : ''}`
// Добавляем таймаут 8 секунд для fetch запроса
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 8000) // 8 секунд
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, teacherName, url, parseWeekNavigation, true)
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}`)
const error = e instanceof Error ? e : new Error(String(e))
logErrorToFile(error, {
type: 'parsing_error',
teacherName,
url,
teacherID
})
reportParserError(new Date().toISOString(), 'Не удалось сделать парсинг для преподавателя', teacherName)
throw e
}
} else {
// Логируем только метаданные, без содержимого ответа
console.error(`Failed to fetch schedule: status=${page.status}, contentType=${contentType}, contentLength=${content.length}`)
const error = new Error(`Error while fetching ${PROXY_URL}: status ${page.status}`)
logErrorToFile(error, {
type: 'fetch_error',
teacherName,
url,
teacherID,
status: page.status,
contentType
})
reportParserError(new Date().toISOString(), 'Не удалось получить страницу для преподавателя', teacherName)
throw error
}
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
const timeoutError = new ScheduleTimeoutError(`Request timeout while fetching ${PROXY_URL}`)
logErrorToFile(timeoutError, {
type: 'timeout_error',
teacherName,
url,
teacherID
})
throw timeoutError
}
// Улучшенная обработка сетевых ошибок для диагностики
const errorObj = error instanceof Error ? error : new Error(String(error))
if (errorObj && 'cause' in errorObj && errorObj.cause instanceof Error) {
const networkError = errorObj.cause as Error & { code?: string }
if (networkError.code === 'ECONNRESET' || networkError.code === 'ECONNREFUSED' || networkError.code === 'ETIMEDOUT') {
console.error(`Network error while fetching ${PROXY_URL}:`, {
code: networkError.code,
message: networkError.message,
url
})
logErrorToFile(errorObj, {
type: 'network_error',
teacherName,
url,
teacherID,
networkErrorCode: networkError.code,
networkErrorMessage: networkError.message
})
} else {
// Логируем другие ошибки тоже
logErrorToFile(errorObj, {
type: 'unknown_error',
teacherName,
url,
teacherID
})
}
} else {
// Логируем ошибки без cause
logErrorToFile(errorObj, {
type: 'unknown_error',
teacherName,
url,
teacherID
})
}
throw error
}
}

View File

@@ -13,9 +13,32 @@ export type ParseResult = {
}
const dayTitleParser = (text: string) => {
const [dateString, week] = text.trim().split(' / ')
const weekNumber = Number(week.trim().match(/^(\d+) неделя$/)![1])
const [, day, month, year] = dateString.trim().match(/^[а-яА-Я]+ (\d{1,2})\.(\d{1,2})\.(\d{4})$/)!
const trimmed = text.trim()
// Поддерживаем оба формата: с пробелом " / " и без пробела "/"
const parts = trimmed.split(/\s*\/\s*/)
if (parts.length < 2) {
throw new Error(`Invalid day title format: ${trimmed}`)
}
const [dateString, week] = parts
if (!dateString || !week) {
throw new Error(`Invalid day title format: ${trimmed}`)
}
const weekMatch = week.trim().match(/^(\d+)\s+неделя$/)
if (!weekMatch) {
throw new Error(`Invalid week format: ${week}`)
}
const weekNumber = Number(weekMatch[1])
const dateMatch = dateString.trim().match(/^[а-яА-Я]+ (\d{1,2})\.(\d{1,2})\.(\d{4})$/)
if (!dateMatch) {
throw new Error(`Invalid date format: ${dateString}`)
}
const [, day, month, year] = dateMatch
const date = new Date(Number(year), Number(month) - 1, Number(day), 12)
return { date, weekNumber }
}
@@ -232,7 +255,7 @@ function parseWeekNavigation(document: Document, currentWeekNumber: number, curr
return weeks.sort((a, b) => a.weekNumber - b.weekNumber)
}
const parseLesson = (row: Element): Lesson | null => {
const parseLesson = (row: Element, isTeacherSchedule: boolean = false): Lesson | null => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const lesson: LessonObject = {}
@@ -242,34 +265,98 @@ const parseLesson = (row: Element): Lesson | null => {
lesson.isChange = cells.every(td => td.getAttribute('bgcolor') === 'ffffbb')
// Пропускаем урок только если это НЕ замена И в ячейке "Свободное время"
if (!cells[3] || (!lesson.isChange && cells[3].textContent?.trim() === 'Свободное время')) return null
// Проверяем наличие необходимых ячеек
if (cells.length < 4) {
// Для преподавателей может быть другая структура - проверяем минимум ячеек
if (cells.length < 2) {
return null
}
}
if (!cells[1] || !cells[1].childNodes[0]) {
// Для преподавателей ячейка с предметом может быть в другом индексе
const disciplineCellIndex = cells.length >= 4 ? 3 : (cells.length >= 3 ? 2 : 1)
const disciplineCell = cells[disciplineCellIndex]
// Пропускаем урок только если это НЕ замена И в ячейке "Свободное время"
if (disciplineCell && !lesson.isChange && disciplineCell.textContent?.trim() === 'Свободное время') {
return null
}
const timeCell = cells[1].childNodes
const timeText = timeCell[0].textContent?.trim()
// Проверяем наличие ячейки времени
if (!cells[1]) {
return null
}
// Для времени может быть разная структура
let timeText = ''
if (cells[1].childNodes[0]) {
timeText = cells[1].childNodes[0].textContent?.trim() || ''
} else {
// Если нет childNodes, берем весь текст ячейки
timeText = cells[1].textContent?.trim() || ''
}
if (!timeText) {
return null
}
// Парсим время (уже извлечено выше)
const [startTime, endTime] = timeText.split(' ')
lesson.time = {
start: startTime ?? '',
end: endTime ?? ''
}
// Пытаемся найти hint для времени
const timeCell = cells[1].childNodes
if (timeCell[2]) {
lesson.time.hint = timeCell[2].textContent?.trim()
}
try {
const disciplineCell = cells[3]
if (!disciplineCell) {
throw new Error('Discipline cell not found')
}
const cellText = disciplineCell.textContent || ''
const cellHTML = disciplineCell.innerHTML || ''
let cellText = disciplineCell.textContent || ''
let cellHTML = disciplineCell.innerHTML || ''
// Для преподавателей данные могут быть в другой ячейке или в объединенной ячейке
// Если ячейка пустая, проверяем другие ячейки
if (!cellText && !cellHTML && cells.length > disciplineCellIndex) {
// Пробуем следующую ячейку
for (let i = disciplineCellIndex + 1; i < cells.length; i++) {
const altCell = cells[i]
const altText = altCell.textContent?.trim() || ''
const altHTML = altCell.innerHTML?.trim() || ''
if (altText || altHTML) {
cellText = altText
cellHTML = altHTML
break
}
}
}
// Если все еще пусто, пробуем объединенную ячейку (может быть в первой ячейке)
if (!cellText && !cellHTML && cells.length > 0) {
const firstCell = cells[0]
const firstText = firstCell.textContent?.trim() || ''
// Проверяем, содержит ли первая ячейка данные об уроке (длинный текст)
if (firstText.length > 20 && /[А-ЯЁа-яё]/.test(firstText)) {
cellText = firstText
cellHTML = firstCell.innerHTML?.trim() || ''
}
}
// Для преподавателей может быть другая структура - проверяем наличие данных
if (!cellText && !cellHTML) {
// Вместо ошибки, используем fallback
const allText = row.textContent?.trim() || ''
if (allText && allText.length > 10) {
cellText = allText
} else {
throw new Error('Discipline cell is empty')
}
}
// Проверяем, является ли это заменой "Свободное время" на пару
const isFreeTimeReplacement = lesson.isChange &&
@@ -307,18 +394,20 @@ const parseLesson = (row: Element): Lesson | null => {
const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1
const afterSecondBr = afterFirstBr.substring(secondBrEnd)
const fontIndex = afterSecondBr.indexOf('<font')
if (fontIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, fontIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
// Если нет <font>, преподаватель может быть до следующего <br> или до конца
const thirdBrIndex = afterSecondBr.indexOf('<br')
if (thirdBrIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, thirdBrIndex)
if (!isTeacherSchedule) {
const fontIndex = afterSecondBr.indexOf('<font')
if (fontIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, fontIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim()
// Если нет <font>, преподаватель может быть до следующего <br> или до конца
const thirdBrIndex = afterSecondBr.indexOf('<br')
if (thirdBrIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, thirdBrIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim()
}
}
}
} else {
@@ -339,7 +428,7 @@ const parseLesson = (row: Element): Lesson | null => {
const fontContent = fontMatch[1]
// Ищем паттерн: <br> адрес <br> Кабинет: номер
// Сначала убираем все теги и разбиваем по <br>
const cleanContent = fontContent.replace(/<[^>]+>/g, '|').split('|').filter(p => p.trim())
const cleanContent = fontContent.replace(/<[^>]+>/g, '|').split('|').filter((p: string) => p.trim())
// Ищем адрес (первая непустая часть) и кабинет (часть с "Кабинет:")
for (let i = 0; i < cleanContent.length; i++) {
const part = cleanContent[i].trim()
@@ -392,18 +481,20 @@ const parseLesson = (row: Element): Lesson | null => {
const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1
const afterSecondBr = afterFirstBr.substring(secondBrEnd)
const fontIndex = afterSecondBr.indexOf('<font')
if (fontIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, fontIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
// Если нет <font>, преподаватель может быть до следующего <br> или до конца
const thirdBrIndex = afterSecondBr.indexOf('<br')
if (thirdBrIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, thirdBrIndex)
if (!isTeacherSchedule) {
const fontIndex = afterSecondBr.indexOf('<font')
if (fontIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, fontIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim()
// Если нет <font>, преподаватель может быть до следующего <br> или до конца
const thirdBrIndex = afterSecondBr.indexOf('<br')
if (thirdBrIndex !== -1) {
const teacherHTML = afterSecondBr.substring(0, thirdBrIndex)
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
} else {
lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim()
}
}
}
} else {
@@ -424,7 +515,7 @@ const parseLesson = (row: Element): Lesson | null => {
const fontContent = fontMatch[1]
// Ищем паттерн: <br> адрес <br> Кабинет: номер
// Сначала убираем все теги и разбиваем по <br>
const cleanContent = fontContent.replace(/<[^>]+>/g, '|').split('|').filter(p => p.trim())
const cleanContent = fontContent.replace(/<[^>]+>/g, '|').split('|').filter((p: string) => p.trim())
// Ищем адрес (первая непустая часть) и кабинет (часть с "Кабинет:")
for (let i = 0; i < cleanContent.length; i++) {
const part = cleanContent[i].trim()
@@ -453,16 +544,66 @@ const parseLesson = (row: Element): Lesson | null => {
}
} else {
// Обычный парсинг для нормальных пар
if (!disciplineCell.childNodes[0]) {
throw new Error('Subject node not found')
}
lesson.subject = disciplineCell.childNodes[0].textContent!.trim()
// Для преподавателей структура может отличаться - пробуем разные варианты
let subjectText = ''
const teacherCell = disciplineCell.childNodes[2]
if (teacherCell) {
lesson.teacher = teacherCell.textContent!.trim()
// Вариант 1: первый childNode (как для групп)
if (disciplineCell.childNodes[0]) {
subjectText = disciplineCell.childNodes[0].textContent?.trim() || ''
}
// Вариант 2: если первый childNode пустой, берем весь текст ячейки и извлекаем предмет
if (!subjectText || subjectText.length === 0) {
const fullText = cellText || disciplineCell.innerHTML?.trim() || ''
// Для преподавателей формат может быть: "ПредметГруппа(Аудитория)Адрес"
// Пример: "Теория вероятностей и математическая статистикаАндрющенко А.В.(ИСПВ-9)Моско"
// Извлекаем: "Теория вероятностей и математическая статистика"
if (fullText) {
// Паттерн 1: "Предмет[ФИО](Группа)Адрес"
// Ищем группу в скобках и извлекаем текст до неё
const groupMatch = fullText.match(/^([А-ЯЁа-яё\s]+?)(?:[А-ЯЁ][а-яё]+\s+[А-ЯЁ]\.[А-ЯЁ]\.)?(\([^)]+\))/)
if (groupMatch) {
subjectText = groupMatch[1].trim()
} else {
// Паттерн 2: "Предмет(Группа)Адрес" - без ФИО
const groupPatternMatch = fullText.match(/^([А-ЯЁа-яё\s]+?)(\([А-ЯЁ]+-\d+[к]?\))/)
if (groupPatternMatch) {
subjectText = groupPatternMatch[1].trim()
} else {
// Паттерн 3: просто название предмета, но убираем возможные имена преподавателей и группы в конце
// Убираем ФИО в формате "Фамилия И.О." и группу в скобках
const cleanedText = fullText.replace(/\s*[А-ЯЁ][а-яё]+\s+[А-ЯЁ]\.[А-ЯЁ]\.\s*\([^)]+\)[А-ЯЁа-яё]*$/, '').trim()
// Если после очистки остался длинный текст (больше 10 символов), это предмет
if (cleanedText.length > 10) {
subjectText = cleanedText
} else {
subjectText = fullText
}
}
}
}
}
if (!subjectText || subjectText.length === 0) {
// Используем fallback - берем весь текст ячейки
const fallbackText = disciplineCell.textContent?.trim() || ''
if (fallbackText) {
lesson.fallbackDiscipline = fallbackText
} else {
throw new Error('Subject node not found')
}
} else {
lesson.subject = subjectText
}
if (!isTeacherSchedule) {
const teacherCell = disciplineCell.childNodes[2]
if (teacherCell) {
lesson.teacher = teacherCell.textContent!.trim()
}
}
// Парсим место проведения
const placeCell = disciplineCell.childNodes[3]
if (placeCell && placeCell.childNodes.length > 0) {
@@ -481,6 +622,25 @@ const parseLesson = (row: Element): Lesson | null => {
}
}
}
} else if (isTeacherSchedule) {
// Для преподавателей место может быть в другом формате в тексте ячейки
// Формат: "ПредметГруппа(Аудитория)Адрес"
const fullText = disciplineCell.textContent?.trim() || ''
if (fullText) {
// Ищем паттерн: группа в скобках и адрес после
// Например: "(ИКС-8)Московское шоссе, 120" или "(ССА-15к)Моск"
const placeMatch = fullText.match(/\(([^)]+)\)([^(]+?)(?:\d+|$)/)
if (placeMatch) {
const classroom = placeMatch[1].trim()
const address = placeMatch[2].trim()
if (classroom && address) {
lesson.place = {
address,
classroom
}
}
}
}
}
}
} catch(e) {
@@ -515,10 +675,108 @@ const parseLesson = (row: Element): Lesson | null => {
}
}
export function parsePage(document: Document, groupName: string, url?: string, shouldParseWeekNavigation: boolean = true): ParseResult {
export function parsePage(document: Document, groupName: string, url?: string, shouldParseWeekNavigation: boolean = true, isTeacherSchedule: boolean = false): ParseResult {
const tables = Array.from(document.querySelectorAll('body > table'))
const table = tables.find(table => table.querySelector(':scope > tbody > tr:first-child')?.textContent?.trim() === groupName)
const rows = Array.from(table!.children[0].children).filter(el => el.tagName === 'TR').slice(2)
// Пытаемся найти таблицу разными способами
let table: Element | undefined
// Для расписания преподавателей приоритет - таблица с признаками расписания, а не список имен
if (isTeacherSchedule) {
// Способ 1: Ищем таблицу с признаками расписания (дни недели с датами, время пар)
table = tables.find(table => {
const tableText = table.textContent || ''
// Проверяем наличие заголовков дней с датами и номерами недель
const hasDayTitles = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя/i.test(tableText)
// Проверяем наличие времени пар
const hasTimeSlots = /\d{1,2}:\d{2}\s*\s*\d{1,2}:\d{2}/.test(tableText)
// НЕ должно быть списка имен преподавателей (много строк с ФИО подряд)
const hasManyNames = (tableText.match(/[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+/g) || []).length > 20
return (hasDayTitles || hasTimeSlots) && !hasManyNames
})
// Способ 2: Если не нашли, ищем таблицу по имени в первой строке (может быть заголовок)
if (!table) {
table = tables.find(table => {
const firstRow = table.querySelector(':scope > tbody > tr:first-child')
return firstRow?.textContent?.trim() === groupName
})
}
} else {
// Для групп: ищем по имени в первой строке
table = tables.find(table => {
const firstRow = table.querySelector(':scope > tbody > tr:first-child')
return firstRow?.textContent?.trim() === groupName
})
}
// Способ 3: Если не нашли, ищем таблицу, которая содержит имя где-то внутри
if (!table) {
table = tables.find(table => {
const tableText = table.textContent || ''
// Проверяем, содержит ли таблица имя (может быть в заголовке или в первой строке)
return tableText.includes(groupName)
})
}
// Способ 4: Если все еще не нашли, берем первую таблицу с расписанием (содержит заголовки дней)
if (!table && tables.length > 0) {
table = tables.find(table => {
const tableText = table.textContent || ''
// Проверяем наличие признаков расписания (дни недели, время пар)
return /Понедельник|Вторник|Среда|Четверг|Пятница|Суббота/.test(tableText) ||
/\d{1,2}:\d{2}\s*\s*\d{1,2}:\d{2}/.test(tableText)
})
}
// Способ 5: Если ничего не помогло, берем самую большую таблицу (обычно это расписание)
if (!table && tables.length > 0) {
table = tables.reduce((largest, current) => {
const largestRows = largest.querySelectorAll('tr').length
const currentRows = current.querySelectorAll('tr').length
return currentRows > largestRows ? current : largest
})
}
if (!table) {
// Логируем информацию о найденных таблицах для отладки
console.log(`[parsePage] Found ${tables.length} tables, analyzing...`)
tables.forEach((t, i) => {
const text = t.textContent?.substring(0, 200) || ''
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 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}"`)
})
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`)
// Пытаемся найти tbody или использовать прямые children таблицы
let tbody: HTMLTableSectionElement | null = null
if (table) {
const tbodyElement = table.querySelector('tbody')
if (tbodyElement) {
tbody = tbodyElement as HTMLTableSectionElement
} else if (table.children.length > 0 && table.children[0].tagName === 'TBODY') {
tbody = table.children[0] as HTMLTableSectionElement
}
if (!tbody && table.children.length === 0) {
throw new Error(`Table structure is invalid for ${groupName}`)
}
}
// Получаем строки из tbody или напрямую из таблицы
const allRows = tbody
? Array.from(tbody.querySelectorAll('tr'))
: Array.from(table.querySelectorAll('tr'))
const rows = allRows.slice(2)
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 = []
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -535,36 +793,137 @@ export function parsePage(document: Document, groupName: string, url?: string, s
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const rowText = row.textContent?.trim() || ''
const isDivider = row.textContent?.trim() === ''
const isDayTitle = dayLessons.length === 0 && !('date' in dayInfo)
const isDivider = rowText === ''
// Проверяем, является ли строка заголовком дня: должна содержать паттерн "день недели дата / номер неделя"
// Поддерживаем оба формата: с пробелом и без пробела перед "/"
const looksLikeDayTitle = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя/i.test(rowText)
// Заголовок дня может быть в любой момент - либо когда нет дня, либо когда начинается новый день
const isDayTitle = looksLikeDayTitle
// Если уже есть день с датой и встречаем новый заголовок дня, сохраняем предыдущий день
const isNewDayTitle = looksLikeDayTitle && ('date' in dayInfo)
const isTableHeader = previousRowIsDayTitle
if (isDivider) {
// Если встречаем новый день, сохраняем предыдущий
if (isNewDayTitle && 'date' in dayInfo) {
days.push({ ...dayInfo, lessons: dayLessons })
dayLessons = []
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
dayInfo = {}
previousRowIsDayTitle = false
}
if (isDivider) {
// Сохраняем день при разделителе, только если есть данные
if ('date' in dayInfo) {
days.push({ ...dayInfo, lessons: dayLessons })
dayLessons = []
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
dayInfo = {}
}
previousRowIsDayTitle = false
} else if (isTableHeader) {
// После заголовка дня идет строка заголовков таблицы - пропускаем её
// НО dayInfo должен сохраниться для следующих строк!
previousRowIsDayTitle = false
continue
} else if (isDayTitle) {
const { date, weekNumber } = dayTitleParser(row.querySelector('h3')!.textContent!)
dayInfo.date = date
dayInfo.weekNumber = weekNumber
if (!currentWeekNumber) {
currentWeekNumber = weekNumber
// Пытаемся найти заголовок дня в разных форматах
const h3Element = row.querySelector('h3')
let dayTitleText = h3Element?.textContent?.trim() || row.textContent?.trim() || ''
// Извлекаем только часть до переноса строки или до начала следующего контента
// Заголовок дня может быть в начале строки, а дальше идет другой контент
const dayTitleMatch = dayTitleText.match(/((Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя)/i)
if (dayTitleMatch) {
dayTitleText = dayTitleMatch[1]
}
if (!dayTitleText) {
// Пропускаем строку, если не можем найти заголовок
continue
}
try {
const { date, weekNumber } = dayTitleParser(dayTitleText)
console.log(`[parsePage] Parsed day title: ${dayTitleText} -> date: ${date}, week: ${weekNumber}`)
dayInfo.date = date
dayInfo.weekNumber = weekNumber
if (!currentWeekNumber) {
currentWeekNumber = weekNumber
}
previousRowIsDayTitle = true
// Важно: после парсинга заголовка дня, следующий цикл должен обрабатывать уроки
// Поэтому НЕ делаем continue, а просто устанавливаем флаг
// Проверяем, что dayInfo действительно установлен
console.log(`[parsePage] Day info set: date=${dayInfo.date}, weekNumber=${dayInfo.weekNumber}`)
} catch (error) {
// Если не удалось распарсить заголовок, пропускаем строку
console.warn(`[parsePage] Failed to parse day title: ${dayTitleText}`, error)
continue
}
previousRowIsDayTitle = true
} else {
const lesson = parseLesson(row)
if(lesson !== null)
dayLessons.push(lesson)
// Пытаемся распарсить как урок, только если уже есть день
const hasDayContext = 'date' in dayInfo
if (hasDayContext) {
// Пропускаем строки, которые являются только номерами пар или временем (заголовки столбцов)
const cells = Array.from(row.querySelectorAll(':scope > td'))
const cellTexts = cells.map(cell => cell.textContent?.trim() || '').filter(t => t)
// Для преподавателей данные могут быть в одной ячейке в формате "номер\nвремя\n\nпредмет..."
// Проверяем, есть ли в строке данные об уроке
const hasLessonData = cellTexts.some(text => {
// Проверяем наличие предмета (длинный текст с русскими буквами)
return text.length > 20 && /[А-ЯЁа-яё]/.test(text) && !/^\d+$/.test(text) && !/^\d{1,2}:\d{2}\s*\s*\d{1,2}:\d{2}$/.test(text)
})
// Если строка содержит только номер пары и время (например, "1\n08:00 09:30"), пропускаем её
// Но если есть данные об уроке, не пропускаем
if (cells.length <= 2 && cellTexts.length <= 2 && !hasLessonData) {
const isTimeSlotRow = cellTexts.some(text => /^\d+$/.test(text) || /\d{1,2}:\d{2}\s*\s*\d{1,2}:\d{2}/.test(text))
if (isTimeSlotRow) {
continue
}
}
const lesson = parseLesson(row, isTeacherSchedule)
if(lesson !== null) {
let lessonName = 'unknown'
if ('subject' in lesson && lesson.subject) {
lessonName = lesson.subject
} else if ('fallbackDiscipline' in lesson && lesson.fallbackDiscipline) {
lessonName = lesson.fallbackDiscipline
}
console.log(`[parsePage] Parsed lesson: ${lessonName}`)
dayLessons.push(lesson)
} else {
// Логируем строки, которые не распарсились как уроки
console.log(`[parsePage] Failed to parse lesson from row: ${rowText.substring(0, 100)}`)
}
} else {
// Логируем строки, которые не распознаются как дни и не парсятся как уроки
// Но только если это не пустая строка и не заголовок дня
if (rowText && !looksLikeDayTitle) {
const cells = Array.from(row.querySelectorAll(':scope > td'))
if (cells.length > 0) {
console.log(`[parsePage] Skipping row (no day context): ${rowText.substring(0, 100)}`)
}
}
}
}
}
// Добавляем последний день, если он не был добавлен
if ('date' in dayInfo) {
console.log(`[parsePage] Adding final day with ${dayLessons.length} lessons`)
days.push({ ...dayInfo, lessons: dayLessons })
}
console.log(`[parsePage] Total days parsed: ${days.length}`)
// Парсим навигацию по неделям только если включена навигация
let availableWeeks: WeekInfo[] | undefined
let finalCurrentWk = currentWk

View File

@@ -0,0 +1,73 @@
import { JSDOM } from 'jsdom'
export type TeacherListItem = {
parseId: number
name: string
}
/**
* Парсит страницу со списком преподавателей (?mn=3)
* Извлекает список преподавателей из таблицы/ссылок
* @param document - DOM документ страницы
* @returns Массив преподавателей с parseId и именем
*/
export function parseTeachersList(document: Document): TeacherListItem[] {
const teachers: TeacherListItem[] = []
// Ищем все ссылки, которые содержат ?mn=3&obj=
const links = Array.from(document.querySelectorAll('a[href*="?mn=3&obj="], a[href*="mn=3&obj="]'))
for (const link of links) {
const href = link.getAttribute('href')
if (!href) continue
// Парсим URL вида ?mn=3&obj=XXX или /?mn=3&obj=XXX
const objMatch = href.match(/[?&]obj=(\d+)/)
if (!objMatch) continue
const parseId = Number(objMatch[1])
if (isNaN(parseId) || parseId <= 0) continue
// Извлекаем имя преподавателя из текста ссылки
const name = link.textContent?.trim()
if (!name || name.length === 0) continue
// Проверяем, что это не дубликат
if (!teachers.find(t => t.parseId === parseId)) {
teachers.push({ parseId, name })
}
}
// Если не нашли ссылки, пытаемся найти в таблице
if (teachers.length === 0) {
const tables = Array.from(document.querySelectorAll('table'))
for (const table of tables) {
const rows = Array.from(table.querySelectorAll('tr'))
for (const row of rows) {
const link = row.querySelector('a[href*="obj="]')
if (!link) continue
const href = link.getAttribute('href')
if (!href || !href.includes('mn=3')) continue
const objMatch = href.match(/[?&]obj=(\d+)/)
if (!objMatch) continue
const parseId = Number(objMatch[1])
if (isNaN(parseId) || parseId <= 0) continue
const name = link.textContent?.trim() || row.textContent?.trim()
if (!name || name.length === 0) continue
if (!teachers.find(t => t.parseId === parseId)) {
teachers.push({ parseId, name })
}
}
}
}
// Сортируем по имени
teachers.sort((a, b) => a.name.localeCompare(b.name))
return teachers
}

View File

@@ -0,0 +1,96 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { loadTeachers, saveTeachers, clearTeachersCache, TeachersData } from '@/shared/data/teachers-loader'
import { parseTeachersList } from '@/app/parser/teachers-list'
import { JSDOM } from 'jsdom'
import { PROXY_URL } from '@/shared/constants/urls'
import contentTypeParser from 'content-type'
type ResponseData = ApiResponse<{
teachers?: TeachersData
parsed?: number
}>
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method === 'GET') {
// Получение списка преподавателей (всегда свежие данные для админ-панели)
clearTeachersCache()
const teachers = loadTeachers(true)
res.status(200).json({ teachers })
return
}
if (req.method === 'POST') {
// Парсинг и обновление списка преподавателей
try {
const url = `${PROXY_URL}/?mn=3`
// Добавляем таймаут 10 секунд для fetch запроса
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
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') {
res.status(500).json({ error: `Failed to fetch teachers list: status ${page.status}` })
return
}
const dom = new JSDOM(content, { url })
const document = dom.window.document
const teachersList = parseTeachersList(document)
// Закрываем JSDOM для освобождения памяти
dom.window.close()
if (teachersList.length === 0) {
res.status(500).json({ error: 'No teachers found on the page' })
return
}
// Преобразуем список в формат TeachersData
// Используем parseId как id (строковое представление)
const teachersData: TeachersData = {}
for (const teacher of teachersList) {
const id = String(teacher.parseId)
teachersData[id] = {
parseId: teacher.parseId,
name: teacher.name
}
}
// Сохраняем в БД
saveTeachers(teachersData)
// Сохраняем timestamp последнего обновления
const { setTeachersLastUpdateTime } = await import('@/shared/data/database')
setTeachersLastUpdateTime(Date.now())
// Сбрасываем кеш и загружаем свежие данные из БД
clearTeachersCache()
const updatedTeachers = loadTeachers(true)
res.status(200).json({
success: true,
teachers: updatedTeachers,
parsed: teachersList.length
})
return
} catch (error) {
console.error('Error parsing teachers list:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
res.status(500).json({ error: `Failed to parse teachers list: ${errorMessage}` })
return
}
}
}
export default withAuth(handler, ['GET', 'POST'])

View File

@@ -235,10 +235,20 @@ export default function HomePage(props: HomePageProps) {
)}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
>
<Link href="/teachers">
<Button variant="secondary" className="gap-2">
Преподаватели
</Button>
</Link>
</div>
{showAddGroupButton && (
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.08}s` } as React.CSSProperties}
>
<Button
variant="secondary"
@@ -252,13 +262,13 @@ export default function HomePage(props: HomePageProps) {
)}
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.08 : 0.05)}s` } as React.CSSProperties}
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.11 : 0.08)}s` } as React.CSSProperties}
>
<ThemeSwitcher />
</div>
<div
className="stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.11 : 0.08)}s` } as React.CSSProperties}
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + (showAddGroupButton ? 0.14 : 0.11)}s` } as React.CSSProperties}
>
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
<Button variant="outline" className="gap-2">

View File

@@ -0,0 +1,376 @@
import { Schedule } from '@/widgets/schedule'
import { Day } from '@/shared/model/day'
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
import { getTeacherSchedule, ScheduleResult, ScheduleTimeoutError } from '@/app/agregator/schedule'
import { NextSerialized, nextDeserialized, nextSerialized } from '@/app/utils/date-serializer'
import { NavBar } from '@/widgets/navbar'
import { LastUpdateAt } from '@/entities/last-update-at'
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
import { getTeacherByParseId } from '@/shared/data/database'
import { SITE_URL } from '@/shared/constants/urls'
import crypto from 'crypto'
import React from 'react'
import { getDayOfWeek } from '@/shared/utils'
import Head from 'next/head'
import { WeekInfo } from '@/app/parser/schedule'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shadcn/ui/card'
import { AlertCircle } from 'lucide-react'
type PageProps = {
schedule?: Day[]
teacher: {
id: string
name: string
}
parsedAt?: Date
cacheAvailableFor: string[]
groups: GroupsData
currentWk?: number | null
availableWeeks?: WeekInfo[] | null
settings: AppSettings
error?: {
message: string
isTimeout: boolean
}
isFromCache?: boolean
cacheAge?: number // возраст кэша в минутах
cacheInfo?: {
size: number
entries: number
}
}
export default function TeacherPage(props: NextSerialized<PageProps>) {
const { schedule, teacher, cacheAvailableFor, parsedAt, groups, currentWk, availableWeeks, settings, error, isFromCache, cacheAge, cacheInfo } = nextDeserialized<PageProps>(props)
React.useEffect(() => {
if (typeof window === 'undefined' || error) return
// Используем 'auto' для нормальной работы обновления страницы
if ('scrollRestoration' in history) {
history.scrollRestoration = 'auto'
}
let attempts = 0
const MAX_ATTEMPTS = 50 // Максимум 5 секунд (50 * 100ms)
const interval = setInterval(() => {
attempts++
const today = getDayOfWeek(new Date())
const todayBlock = document.getElementById(today)
if (todayBlock) {
const GAP = 48
const HEADER_HEIGHT = 64
window.scrollTo({ top: todayBlock.offsetTop - GAP - HEADER_HEIGHT, behavior: 'smooth' })
clearInterval(interval)
} else if (attempts >= MAX_ATTEMPTS) {
// Прекращаем попытки после максимального количества
clearInterval(interval)
}
}, 100)
// Cleanup функция для очистки интервала при размонтировании
return () => {
clearInterval(interval)
}
}, [schedule, error])
return (
<>
<Head>
<title>{error ? `Ошибка — Расписание преподавателя ${teacher.name}` : `Преподаватель ${teacher.name} — Расписание занятий в Колледже Связи`}</title>
<link rel="canonical" href={`${SITE_URL}/teacher/${teacher.id}`} />
<meta name="description" content={error ? `Не удалось загрузить расписание преподавателя ${teacher.name}` : `Расписание занятий преподавателя ${teacher.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
<meta property="og:title" content={error ? `Ошибка — Расписание преподавателя ${teacher.name}` : `Преподаватель ${teacher.name} — Расписание занятий в Колледже Связи`} />
<meta property="og:description" content={error ? `Не удалось загрузить расписание преподавателя ${teacher.name}` : `Расписание занятий преподавателя ${teacher.name} на неделю в Колледже Связи ПГУТИ. Расписание пар, материалы для подготовки и изменения в расписании.`} />
</Head>
<NavBar cacheAvailableFor={cacheAvailableFor} groups={groups} isTeacherPage={true} />
{error ? (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<Card className="stagger-card">
<CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="h-6 w-6 text-destructive" />
<CardTitle>Не удалось загрузить расписание</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
{error.isTimeout
? 'Превышено время ожидания ответа от сервера. Пожалуйста, попробуйте обновить страницу через несколько минут.'
: 'Произошла ошибка при загрузке расписания. Пожалуйста, попробуйте обновить страницу позже.'}
</CardDescription>
</CardContent>
</Card>
</div>
) : (
<>
{parsedAt && <LastUpdateAt date={parsedAt} />}
{schedule && (
<Schedule
days={schedule}
currentWk={currentWk ?? null}
availableWeeks={availableWeeks ?? null}
weekNavigationEnabled={settings.weekNavigationEnabled}
isFromCache={isFromCache}
cacheAge={cacheAge}
cacheInfo={cacheInfo}
hideTeacher={true}
/>
)}
</>
)}
</>
)
}
const cachedTeacherSchedules = new Map<string, { lastFetched: Date, results: ScheduleResult }>()
const maxCacheDurationInMS = 1000 * 60 * 15 // 15 минут для нормального использования кэша
const fallbackCacheDurationInMS = 1000 * 60 * 60 * 24 // 24 часа для fallback кэша при ошибках парсинга
const maxCacheSize = 50 // Максимальное количество записей в кэше (только текущие недели)
// Очистка старых записей из кэша
function cleanupCache() {
const now = Date.now()
const entriesToDelete: string[] = []
// Находим устаревшие записи (используем fallback TTL для сохранения кэша при ошибках)
for (const [key, value] of cachedTeacherSchedules.entries()) {
if (now - value.lastFetched.getTime() >= fallbackCacheDurationInMS) {
entriesToDelete.push(key)
}
}
// Удаляем устаревшие записи
entriesToDelete.forEach(key => cachedTeacherSchedules.delete(key))
// Если кэш все еще слишком большой, удаляем самые старые записи
if (cachedTeacherSchedules.size > maxCacheSize) {
const sortedEntries = Array.from(cachedTeacherSchedules.entries())
.sort((a, b) => a[1].lastFetched.getTime() - b[1].lastFetched.getTime())
const toRemove = sortedEntries.slice(0, cachedTeacherSchedules.size - maxCacheSize)
toRemove.forEach(([key]) => cachedTeacherSchedules.delete(key))
}
}
export async function getServerSideProps(context: GetServerSidePropsContext<{ teacher: string }>): Promise<GetServerSidePropsResult<NextSerialized<PageProps>>> {
const groups = loadGroups()
const settings = loadSettings()
const teacherParam = context.params?.teacher
const wkParam = context.query.wk
// Валидация wk параметра: проверка на валидное число (не NaN, не Infinity)
const wk = wkParam && !isNaN(Number(wkParam)) && isFinite(Number(wkParam)) && Number.isInteger(Number(wkParam)) && Number(wkParam) > 0
? Number(wkParam)
: undefined
// Валидация teacher параметра: должен быть числом (parseId)
const teacherParseId = teacherParam && !isNaN(Number(teacherParam)) && isFinite(Number(teacherParam)) && Number.isInteger(Number(teacherParam)) && Number(teacherParam) > 0
? Number(teacherParam)
: null
if (!teacherParseId) {
return {
notFound: true
}
}
const teacherInfo = getTeacherByParseId(teacherParseId)
if (!teacherInfo) {
return {
notFound: true
}
}
// Проверяем debug опции
const debug = settings.debug || {}
// Debug: принудительно показать ошибку
if (debug.forceError) {
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[0])
return {
props: nextSerialized({
teacher: {
id: teacherInfo.id,
name: teacherInfo.name
},
cacheAvailableFor,
groups,
settings,
error: {
message: 'Debug: принудительная ошибка',
isTimeout: false
}
}) as NextSerialized<PageProps>
}
}
// Debug: принудительно симулировать таймаут
if (debug.forceTimeout) {
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[0])
return {
props: nextSerialized({
teacher: {
id: teacherInfo.id,
name: teacherInfo.name
},
cacheAvailableFor,
groups,
settings,
error: {
message: 'Debug: принудительный таймаут',
isTimeout: true
}
}) as NextSerialized<PageProps>
}
}
let scheduleResult: ScheduleResult
let parsedAt
let isFromCache = false
let cacheAge: number | undefined
// Очищаем старые записи из кэша перед использованием
cleanupCache()
// Кэшируем только текущую неделю (без параметра wk)
// Если запрашивается конкретная неделя (wk указан), не используем кэш
const useCache = !wk
const cacheKey = `teacher_${teacherParseId}` // Ключ кэша для преподавателя
const cachedSchedule = useCache ? cachedTeacherSchedules.get(cacheKey) : undefined
// Debug: принудительно использовать кэш
if (debug.forceCache && cachedSchedule) {
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
isFromCache = true
const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime()
cacheAge = Math.floor(cacheAgeMs / (1000 * 60))
} else if (cachedSchedule?.lastFetched && Date.now() - cachedSchedule.lastFetched.getTime() < maxCacheDurationInMS) {
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
} else {
try {
// Передаем настройки в getTeacherSchedule для условного парсинга навигации
scheduleResult = await getTeacherSchedule(teacherParseId, teacherInfo.name, wk, settings.weekNavigationEnabled)
parsedAt = new Date()
// Кэшируем только текущую неделю
if (useCache) {
cachedTeacherSchedules.set(cacheKey, { lastFetched: new Date(), results: scheduleResult })
// Очищаем кэш после добавления новой записи, если он стал слишком большим
cleanupCache()
}
} catch(e) {
// При таймауте или любой другой ошибке используем кэш, если он доступен (fallback кэш)
// Используем кэш независимо от возраста при ошибке парсинга
if (cachedSchedule) {
scheduleResult = cachedSchedule.results
parsedAt = cachedSchedule.lastFetched
isFromCache = true
const cacheAgeMs = Date.now() - cachedSchedule.lastFetched.getTime()
cacheAge = Math.floor(cacheAgeMs / (1000 * 60))
// Логируем использование fallback кэша с указанием возраста
if (e instanceof ScheduleTimeoutError) {
console.warn(`Schedule fetch timeout for teacher ${teacherInfo.name}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAge} minutes old)`)
} else {
console.warn(`Schedule fetch error for teacher ${teacherInfo.name}, using fallback cache from ${cachedSchedule.lastFetched.toISOString()} (${cacheAge} minutes old)`)
}
} else {
// Если кэша нет, возвращаем страницу с ошибкой вместо throw
const isTimeout = e instanceof ScheduleTimeoutError
const errorMessage = isTimeout
? 'Превышено время ожидания ответа от сервера'
: 'Произошла ошибка при загрузке расписания'
console.error(`Schedule fetch failed for teacher ${teacherInfo.name}, no cache available:`, e)
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[1]) // Берем parseId из ключа кэша
return {
props: nextSerialized({
teacher: {
id: teacherInfo.id,
name: teacherInfo.name
},
cacheAvailableFor,
groups,
settings,
error: {
message: errorMessage,
isTimeout
}
}) as NextSerialized<PageProps>
}
}
}
}
// Debug: принудительно показать пустое расписание
if (debug.forceEmpty) {
scheduleResult = {
days: [],
currentWk: scheduleResult.currentWk,
availableWeeks: scheduleResult.availableWeeks
}
}
const schedule = scheduleResult.days
const getSha256Hash = (input: string) => {
const hash = crypto.createHash('sha256')
hash.update(input)
return hash.digest('hex')
}
const etag = getSha256Hash(JSON.stringify(nextSerialized(schedule)))
const ifNoneMatch = context.req.headers['if-none-match']
if (ifNoneMatch === etag) {
context.res.writeHead(304, { ETag: `"${etag}"` })
context.res.end()
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Content has not changed
return { props: {} }
}
const cacheAvailableFor = Array.from(cachedTeacherSchedules.entries())
.filter(([, v]) => v.lastFetched.getTime() + maxCacheDurationInMS > Date.now())
.map(([k]) => k.split('_')[1]) // Берем parseId из ключа кэша
// Debug: информация о кэше
const cacheInfo = debug.showCacheInfo ? {
size: cachedTeacherSchedules.size,
entries: cachedTeacherSchedules.size
} : undefined
context.res.setHeader('ETag', `"${etag}"`)
return {
props: nextSerialized({
schedule: schedule,
parsedAt: parsedAt,
teacher: {
id: teacherInfo.id,
name: teacherInfo.name
},
cacheAvailableFor,
groups,
currentWk: scheduleResult.currentWk ?? null,
availableWeeks: scheduleResult.availableWeeks ?? null,
settings,
isFromCache: isFromCache ?? false,
cacheAge: cacheAge ?? null,
cacheInfo: cacheInfo ?? null
}) as NextSerialized<PageProps>
}
}

182
src/pages/teachers.tsx Normal file
View File

@@ -0,0 +1,182 @@
import React from 'react'
import { GetServerSideProps } from 'next'
import { loadTeachers, TeachersData } from '@/shared/data/teachers-loader'
import { Card, CardContent, CardHeader, CardTitle } from '@/shadcn/ui/card'
import { Button } from '@/shadcn/ui/button'
import { ThemeSwitcher } from '@/features/theme-switch'
import Link from 'next/link'
import Head from 'next/head'
import { GITHUB_REPO_URL } from '@/shared/constants/urls'
import { FaGithub } from 'react-icons/fa'
import { ArrowLeft } from 'lucide-react'
type TeachersPageProps = {
teachers: TeachersData
}
export default function TeachersPage({ teachers }: TeachersPageProps) {
// Преобразуем объект преподавателей в массив и сортируем по имени
const teachersList = Object.entries(teachers)
.map(([id, teacher]) => ({ id, parseId: teacher.parseId, name: teacher.name }))
.sort((a, b) => a.name.localeCompare(b.name))
return (
<>
<Head>
<title>Преподаватели Расписание занятий</title>
<meta name="description" content="Список преподавателей Колледжа Связи ПГУТИ" />
</Head>
<div className="min-h-screen p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-4">
<div className="text-center space-y-2 mb-8 stagger-card" style={{ animationDelay: '0.05s' } as React.CSSProperties}>
<h1 className="text-3xl md:text-4xl font-bold">Преподаватели</h1>
<p className="text-muted-foreground">Выберите преподавателя для просмотра расписания</p>
</div>
{teachersList.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Преподаватели не найдены
</CardContent>
</Card>
) : (
<div className="flex flex-col gap-2">
{teachersList.map((teacher, index) => {
const delay = 0.1 + index * 0.02
return (
<div
key={teacher.id}
className="stagger-card"
style={{
animationDelay: `${delay}s`,
} as React.CSSProperties}
>
<Link href={`/teacher/${teacher.parseId}`}>
<Button
variant="outline"
className="w-full justify-center h-auto py-3 px-4 text-sm sm:text-base"
>
{teacher.name}
</Button>
</Link>
</div>
)
})}
</div>
)}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
<div
className="stagger-card"
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.05}s` } as React.CSSProperties}
>
<Link href="/">
<Button variant="secondary" className="gap-2">
<ArrowLeft className="h-4 w-4" />
На главную
</Button>
</Link>
</div>
<div
className="stagger-card"
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.08}s` } as React.CSSProperties}
>
<ThemeSwitcher />
</div>
<div
className="stagger-card"
style={{ animationDelay: `${0.1 + teachersList.length * 0.02 + 0.11}s` } as React.CSSProperties}
>
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
<Button variant="outline" className="gap-2">
<FaGithub className="h-4 w-4" />
GitHub
</Button>
</Link>
</div>
</div>
</div>
</div>
</>
)
}
async function parseAndSaveTeachers(): Promise<boolean> {
try {
const { parseTeachersList } = await import('@/app/parser/teachers-list')
const { JSDOM } = await import('jsdom')
const { PROXY_URL } = await import('@/shared/constants/urls')
const contentTypeParser = await import('content-type')
const teachersUrl = `${PROXY_URL}/?mn=3`
const page = await fetch(teachersUrl)
const content = await page.text()
const contentType = page.headers.get('content-type')
if (page.status === 200 && contentType && contentTypeParser.default.parse(contentType).type === 'text/html') {
const dom = new JSDOM(content, { url: teachersUrl })
const document = dom.window.document
const teachersList = parseTeachersList(document)
dom.window.close()
if (teachersList.length > 0) {
// Преобразуем в формат TeachersData
const teachersData: TeachersData = {}
for (const teacher of teachersList) {
const id = String(teacher.parseId)
teachersData[id] = {
parseId: teacher.parseId,
name: teacher.name
}
}
// Сохраняем в БД
const { saveTeachers } = await import('@/shared/data/teachers-loader')
saveTeachers(teachersData)
// Сохраняем timestamp последнего обновления
const { setTeachersLastUpdateTime } = await import('@/shared/data/database')
setTeachersLastUpdateTime(Date.now())
return true
}
}
return false
} catch (error) {
console.error('Error parsing teachers:', error)
return false
}
}
export const getServerSideProps: GetServerSideProps<TeachersPageProps> = async () => {
let teachers = loadTeachers()
// Проверяем, нужно ли обновить список преподавателей
const { getTeachersLastUpdateTime } = await import('@/shared/data/database')
const lastUpdate = getTeachersLastUpdateTime()
const now = Date.now()
const ONE_DAY_MS = 1000 * 60 * 60 * 24 // 24 часа в миллисекундах
const shouldUpdate = !lastUpdate || (now - lastUpdate) >= ONE_DAY_MS
const isEmpty = Object.keys(teachers).length === 0
// Если список пуст или прошло 24 часа с последнего обновления, обновляем
if (isEmpty || shouldUpdate) {
// Парсим и сохраняем преподавателей напрямую (без вызова API)
const success = await parseAndSaveTeachers()
if (success) {
// Перезагружаем данные из БД
teachers = loadTeachers(true)
} else if (isEmpty) {
// Если не удалось загрузить и список был пуст, логируем ошибку
console.error('Failed to load teachers list')
}
}
return {
props: {
teachers
}
}
}

View File

@@ -143,6 +143,15 @@ function initializeTables(): void {
password_hash TEXT NOT NULL
)
`)
// Таблица преподавателей
database.exec(`
CREATE TABLE IF NOT EXISTS teachers (
id TEXT PRIMARY KEY,
parseId INTEGER NOT NULL,
name TEXT NOT NULL
)
`)
}
// ==================== Функции для работы с группами ====================
@@ -217,6 +226,128 @@ export function deleteGroup(id: string): void {
database.prepare('DELETE FROM groups WHERE id = ?').run(id)
}
// ==================== Функции для работы с преподавателями ====================
export type TeacherInfo = {
parseId: number
name: string
}
export type TeachersData = { [teacherId: string]: TeacherInfo }
export function getAllTeachers(): TeachersData {
const database = getDatabase()
const rows = database.prepare('SELECT id, parseId, name FROM teachers').all() as Array<{
id: string
parseId: number
name: string
}>
const teachers: TeachersData = {}
for (const row of rows) {
teachers[row.id] = {
parseId: row.parseId,
name: row.name
}
}
return teachers
}
export function getTeacher(id: string): TeacherInfo | null {
const database = getDatabase()
const row = database.prepare('SELECT parseId, name FROM teachers WHERE id = ?').get(id) as {
parseId: number
name: string
} | undefined
if (!row) {
return null
}
return {
parseId: row.parseId,
name: row.name
}
}
export function getTeacherByParseId(parseId: number): { id: string; name: string } | null {
const database = getDatabase()
const row = database.prepare('SELECT id, name FROM teachers WHERE parseId = ?').get(parseId) as {
id: string
name: string
} | undefined
if (!row) {
return null
}
return {
id: row.id,
name: row.name
}
}
export function createTeacher(id: string, teacher: TeacherInfo): void {
const database = getDatabase()
database
.prepare('INSERT INTO teachers (id, parseId, name) VALUES (?, ?, ?)')
.run(id, teacher.parseId, teacher.name)
}
export function updateTeacher(id: string, teacher: Partial<TeacherInfo>): void {
const database = getDatabase()
const existing = getTeacher(id)
if (!existing) {
throw new Error(`Teacher with id ${id} not found`)
}
const updated: TeacherInfo = {
parseId: teacher.parseId !== undefined ? teacher.parseId : existing.parseId,
name: teacher.name !== undefined ? teacher.name : existing.name
}
database
.prepare('UPDATE teachers SET parseId = ?, name = ? WHERE id = ?')
.run(updated.parseId, updated.name, id)
}
export function deleteTeacher(id: string): void {
const database = getDatabase()
database.prepare('DELETE FROM teachers WHERE id = ?').run(id)
}
/**
* Получает timestamp последнего обновления списка преподавателей
*/
export function getTeachersLastUpdateTime(): number | null {
const database = getDatabase()
const row = database.prepare('SELECT value FROM settings WHERE key = ?').get('teachers_last_update') as {
value: string
} | undefined
if (!row) {
return null
}
try {
return Number(row.value)
} catch (error) {
console.error('Error parsing teachers last update time:', error)
return null
}
}
/**
* Сохраняет timestamp последнего обновления списка преподавателей
*/
export function setTeachersLastUpdateTime(timestamp: number): void {
const database = getDatabase()
database
.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run('teachers_last_update', String(timestamp))
}
// ==================== Функции для работы с настройками ====================
export function getSettings(): AppSettings {

View File

@@ -0,0 +1,74 @@
import { getAllTeachers as getAllTeachersFromDB, createTeacher, updateTeacher, deleteTeacher, getTeacher, type TeacherInfo, type TeachersData } from './database'
let cachedTeachers: TeachersData | null = null
let cacheTimestamp: number = 0
const CACHE_TTL_MS = 1000 * 60 // 1 минута
/**
* Загружает преподавателей из базы данных
* Использует кеш с TTL для оптимизации, но всегда загружает свежие данные при необходимости
*/
export function loadTeachers(forceRefresh: boolean = false): TeachersData {
const now = Date.now()
const isCacheValid = cachedTeachers !== null && !forceRefresh && (now - cacheTimestamp) < CACHE_TTL_MS
if (isCacheValid && cachedTeachers !== null) {
return cachedTeachers
}
try {
cachedTeachers = getAllTeachersFromDB()
cacheTimestamp = now
return cachedTeachers
} catch (error) {
console.error('Error loading teachers from database:', error)
// Fallback к пустому объекту
return {}
}
}
/**
* Сохраняет преподавателей в базу данных
*/
export function saveTeachers(teachers: TeachersData): void {
try {
const existingTeachers = getAllTeachersFromDB()
// Определяем, каких преподавателей нужно добавить, обновить или удалить
const existingIds = new Set(Object.keys(existingTeachers))
const newIds = new Set(Object.keys(teachers))
// Добавляем или обновляем преподавателей
for (const [id, teacher] of Object.entries(teachers)) {
if (existingIds.has(id)) {
updateTeacher(id, teacher)
} else {
createTeacher(id, teacher)
}
}
// Удаляем преподавателей, которых больше нет
for (const id of existingIds) {
if (!newIds.has(id)) {
deleteTeacher(id)
}
}
// Сбрасываем кеш и timestamp
cachedTeachers = null
cacheTimestamp = 0
} catch (error) {
console.error('Error saving teachers to database:', error)
throw new Error('Failed to save teachers')
}
}
/**
* Сбрасывает кеш преподавателей (полезно после обновления)
*/
export function clearTeachersCache(): void {
cachedTeachers = null
cacheTimestamp = 0
}
export type { TeacherInfo, TeachersData }

View File

@@ -12,9 +12,10 @@ import { NavContext, NavContextProvider } from '@/shared/context/nav-context'
import { GITHUB_REPO_URL } from '@/shared/constants/urls'
import { GroupsData } from '@/shared/data/groups-loader'
export function NavBar({ cacheAvailableFor, groups }: {
export function NavBar({ cacheAvailableFor, groups, isTeacherPage = false }: {
cacheAvailableFor: string[]
groups: GroupsData
isTeacherPage?: boolean
}) {
const { resolvedTheme } = useTheme()
// Используем состояние для предотвращения проблем с гидратацией
@@ -34,14 +35,14 @@ export function NavBar({ cacheAvailableFor, groups }: {
<div className="flex-1 min-w-0 overflow-x-auto scrollbar-hide">
<ul className="flex gap-2 flex-nowrap">
<li className="flex-shrink-0">
<Link href="/">
<Link href={isTeacherPage ? "/teachers" : "/"}>
<Button
variant="secondary"
className="min-h-[44px] whitespace-nowrap gap-2"
tabIndex={-1}
>
<ArrowLeft className="h-4 w-4" />
<span>К группам</span>
<span>{isTeacherPage ? "К преподавателям" : "К группам"}</span>
</Button>
</Link>
</li>

View File

@@ -4,8 +4,9 @@ import { getDayOfWeek } from '@/shared/utils'
import { Lesson } from '@/widgets/schedule/lesson'
import { cx } from 'class-variance-authority'
export function Day({ day }: {
export function Day({ day, hideTeacher = false }: {
day: DayType
hideTeacher?: boolean
}) {
const dayOfWeek = [
'Понедельник',
@@ -53,6 +54,7 @@ export function Day({ day }: {
width={longNames ? 450 : 350}
lesson={lesson}
animationDelay={i * 0.08}
hideTeacher={hideTeacher}
/>
))}
<div className='snap-start hidden md:block' style={{ flex: `0 0 calc(100vw - 4rem - ${longNames ? 450 : 350}px - 1rem)` }} />

View File

@@ -18,7 +18,8 @@ export function Schedule({
weekNavigationEnabled = true,
isFromCache,
cacheAge,
cacheInfo
cacheInfo,
hideTeacher = false
}: {
days: DayType[]
currentWk: number | null | undefined
@@ -30,6 +31,7 @@ export function Schedule({
size: number
entries: number
}
hideTeacher?: boolean
}) {
const group = useRouter().query['group']
const hasScrolledRef = React.useRef(false)
@@ -223,7 +225,7 @@ export function Schedule({
animationDelay: `${i * 0.1}s`,
} as React.CSSProperties}
>
<Day day={day} />
<Day day={day} hideTeacher={hideTeacher} />
</div>
))
)}

View File

@@ -22,10 +22,11 @@ import { BsFillGeoAltFill } from 'react-icons/bs'
import { RiGroup2Fill } from 'react-icons/ri'
import { ResourcesDialog } from '@/widgets/schedule/resources-dialog'
export function Lesson({ lesson, width = 350, animationDelay }: {
export function Lesson({ lesson, width = 350, animationDelay, hideTeacher = false }: {
lesson: LessonType
width: number
animationDelay?: number
hideTeacher?: boolean
}) {
const [resourcesDialogOpened, setResourcesDialogOpened] = React.useState(false)
@@ -73,7 +74,7 @@ export function Lesson({ lesson, width = 350, animationDelay }: {
{lesson.isChange && <div className='absolute top-0 left-0 w-full h-full bg-gradient-to-br from-[#ffc60026] to-[#95620026] pointer-events-none'></div>}
<CardHeader>
<div className='flex gap-2 md:gap-4'>
{hasTeacher ? (
{!hideTeacher && hasTeacher ? (
<Avatar className="flex-shrink-0">
<AvatarImage
src={getTeacherPhoto(teacherObj?.picture)!}
@@ -99,7 +100,7 @@ export function Lesson({ lesson, width = 350, animationDelay }: {
{lesson.time.start} - {lesson.time.end}{
}{lesson.time.hint && <span className='font-bold'>&nbsp;({lesson.time.hint})</span>}
</CardDescription>
{!isCancelled && hasTeacher && lesson.teacher && (
{!hideTeacher && !isCancelled && hasTeacher && lesson.teacher && (
<CardDescription className='text-xs md:text-sm font-medium break-words'>
{lesson.teacher}
</CardDescription>