feat: schedule of teachers (but one)
i think its many poop code and schedule currently now working properly
This commit is contained in:
@@ -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<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.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('<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')
|
||||
// Для преподавателей структура может отличаться - пробуем разные варианты
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user