diff --git a/src/app/agregator/schedule.ts b/src/app/agregator/schedule.ts index bed70e6..961f98a 100644 --- a/src/app/agregator/schedule.ts +++ b/src/app/agregator/schedule.ts @@ -135,4 +135,123 @@ 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 { + // Валидация параметров + 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 + } } \ No newline at end of file diff --git a/src/app/parser/schedule.ts b/src/app/parser/schedule.ts index 42bd67c..5fbe0f5 100644 --- a/src/app/parser/schedule.ts +++ b/src/app/parser/schedule.ts @@ -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.length < 4) { + // Для преподавателей может быть другая структура - проверяем минимум ячеек + if (cells.length < 2) { + return null + } + } + + // Для преподавателей ячейка с предметом может быть в другом индексе + const disciplineCellIndex = cells.length >= 4 ? 3 : (cells.length >= 3 ? 2 : 1) + const disciplineCell = cells[disciplineCellIndex] + // Пропускаем урок только если это НЕ замена И в ячейке "Свободное время" - if (!cells[3] || (!lesson.isChange && cells[3].textContent?.trim() === 'Свободное время')) return null - - if (!cells[1] || !cells[1].childNodes[0]) { + 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(']+>/g, '').trim() - } else { - // Если нет , преподаватель может быть до следующего
или до конца - const thirdBrIndex = afterSecondBr.indexOf(']+>/g, '').trim() } else { - lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim() + // Если нет , преподаватель может быть до следующего
или до конца + const thirdBrIndex = afterSecondBr.indexOf(']+>/g, '').trim() + } else { + lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim() + } } } } else { @@ -339,7 +428,7 @@ const parseLesson = (row: Element): Lesson | null => { const fontContent = fontMatch[1] // Ищем паттерн:
адрес
Кабинет: номер // Сначала убираем все теги и разбиваем по
- 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(']+>/g, '').trim() - } else { - // Если нет , преподаватель может быть до следующего
или до конца - const thirdBrIndex = afterSecondBr.indexOf(']+>/g, '').trim() } else { - lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim() + // Если нет , преподаватель может быть до следующего
или до конца + const thirdBrIndex = afterSecondBr.indexOf(']+>/g, '').trim() + } else { + lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim() + } } } } else { @@ -424,7 +515,7 @@ const parseLesson = (row: Element): Lesson | null => { const fontContent = fontMatch[1] // Ищем паттерн:
адрес
Кабинет: номер // Сначала убираем все теги и разбиваем по
- 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') + // Для преподавателей структура может отличаться - пробуем разные варианты + let subjectText = '' + + // Вариант 1: первый childNode (как для групп) + if (disciplineCell.childNodes[0]) { + subjectText = disciplineCell.childNodes[0].textContent?.trim() || '' } - lesson.subject = disciplineCell.childNodes[0].textContent!.trim() - - const teacherCell = disciplineCell.childNodes[2] - if (teacherCell) { - lesson.teacher = teacherCell.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,35 +793,136 @@ 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 diff --git a/src/app/parser/teachers-list.ts b/src/app/parser/teachers-list.ts new file mode 100644 index 0000000..f51928e --- /dev/null +++ b/src/app/parser/teachers-list.ts @@ -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 +} diff --git a/src/pages/api/admin/teachers.ts b/src/pages/api/admin/teachers.ts new file mode 100644 index 0000000..26544c7 --- /dev/null +++ b/src/pages/api/admin/teachers.ts @@ -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 +) { + 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']) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index da2ab05..5ba36ea 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -235,10 +235,20 @@ export default function HomePage(props: HomePageProps) { )}
+
+ + + +
{showAddGroupButton && (
+ +
+ ) + })} +
+ )} + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + + ) +} + +async function parseAndSaveTeachers(): Promise { + 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 = 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 + } + } +} diff --git a/src/shared/data/database.ts b/src/shared/data/database.ts index 7f4e999..badefab 100644 --- a/src/shared/data/database.ts +++ b/src/shared/data/database.ts @@ -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): 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 { diff --git a/src/shared/data/teachers-loader.ts b/src/shared/data/teachers-loader.ts new file mode 100644 index 0000000..6e4e205 --- /dev/null +++ b/src/shared/data/teachers-loader.ts @@ -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 } diff --git a/src/widgets/navbar/index.tsx b/src/widgets/navbar/index.tsx index f90f449..5bb02bd 100644 --- a/src/widgets/navbar/index.tsx +++ b/src/widgets/navbar/index.tsx @@ -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 }: {
  • - +
  • diff --git a/src/widgets/schedule/day.tsx b/src/widgets/schedule/day.tsx index 7cbb9a2..5da052a 100644 --- a/src/widgets/schedule/day.tsx +++ b/src/widgets/schedule/day.tsx @@ -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} /> ))}
    diff --git a/src/widgets/schedule/index.tsx b/src/widgets/schedule/index.tsx index 185fa4f..16bd503 100644 --- a/src/widgets/schedule/index.tsx +++ b/src/widgets/schedule/index.tsx @@ -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} > - +
    )) )} diff --git a/src/widgets/schedule/lesson.tsx b/src/widgets/schedule/lesson.tsx index 61c4a91..bc6644c 100644 --- a/src/widgets/schedule/lesson.tsx +++ b/src/widgets/schedule/lesson.tsx @@ -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 &&
    }
    - {hasTeacher ? ( + {!hideTeacher && hasTeacher ? (  ({lesson.time.hint})} - {!isCancelled && hasTeacher && lesson.teacher && ( + {!hideTeacher && !isCancelled && hasTeacher && lesson.teacher && ( {lesson.teacher}