feat: schedule of teachers (but one)
i think its many poop code and schedule currently now working properly
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
73
src/app/parser/teachers-list.ts
Normal file
73
src/app/parser/teachers-list.ts
Normal 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
|
||||
}
|
||||
96
src/pages/api/admin/teachers.ts
Normal file
96
src/pages/api/admin/teachers.ts
Normal 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'])
|
||||
@@ -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">
|
||||
|
||||
376
src/pages/teacher/[teacher].tsx
Normal file
376
src/pages/teacher/[teacher].tsx
Normal 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
182
src/pages/teachers.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
74
src/shared/data/teachers-loader.ts
Normal file
74
src/shared/data/teachers-loader.ts
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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)` }} />
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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'> ({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>
|
||||
|
||||
Reference in New Issue
Block a user