diff --git a/src/app/parser/schedule.ts b/src/app/parser/schedule.ts index 9a96085..12c4dc8 100644 --- a/src/app/parser/schedule.ts +++ b/src/app/parser/schedule.ts @@ -50,12 +50,22 @@ const dayTitleParser = (text: string) => { */ function parseWeekNavigation(document: Document, currentWeekNumber: number, currentWk?: number): WeekInfo[] { const weeks: WeekInfo[] = [] + const wkToWeekNumber = new Map() // Ищем все ссылки, которые содержат параметр wk + // Используем более специфичные селекторы вместо перебора всех элементов const links = Array.from(document.querySelectorAll('a[href*="wk="]')) + + // Также ищем ссылки в onclick (только для ссылок, не всех элементов) const linksWithOnclick = Array.from(document.querySelectorAll('a[onclick*="wk="], a[onclick*="wk"]')) + + // Ищем в формах + const forms = Array.from(document.querySelectorAll('form[action*="wk="], form input[name="wk"]')) + + // Ищем в элементах с data-атрибутами (только те, которые могут содержать ссылки) const elementsWithDataHref = Array.from(document.querySelectorAll('[data-href*="wk="]')) + // Объединяем все найденные элементы (убираем дубликаты) const allLinkElementsSet = new Set() links.forEach(el => allLinkElementsSet.add(el)) linksWithOnclick.forEach(el => allLinkElementsSet.add(el)) @@ -63,112 +73,192 @@ function parseWeekNavigation(document: Document, currentWeekNumber: number, curr const allLinkElements = Array.from(allLinkElementsSet) for (const link of allLinkElements) { + // Пробуем извлечь wk из разных атрибутов const href = link.getAttribute('href') const onclick = link.getAttribute('onclick') + const action = link.getAttribute('action') const dataHref = link.getAttribute('data-href') - const urlString = href || onclick || dataHref || '' + const urlString = href || onclick || action || dataHref || '' if (!urlString) continue + // Парсим URL вида ?mn=2&obj=145&wk=308 или /?mn=2&obj=145&wk=308 const wkMatch = urlString.match(/[?&]wk=(\d+)/) if (wkMatch) { const wk = Number(wkMatch[1]) + + // Пытаемся найти номер недели из текста ссылки const linkText = link.textContent?.trim() || '' const parentText = link.parentElement?.textContent?.trim() || '' const combinedText = `${linkText} ${parentText}` + // Ищем номер недели в тексте const weekNumberMatch = combinedText.match(/(\d+)\s*недел/i) - const weekNumber = weekNumberMatch ? Number(weekNumberMatch[1]) : undefined + let weekNumber = weekNumberMatch ? Number(weekNumberMatch[1]) : undefined - if (weekNumber !== undefined) { - if (!weeks.some(w => w.wk === wk)) { + // Если не нашли в тексте, пытаемся определить по контексту + if (!weekNumber) { + // Проверяем, есть ли указание на "следующую" или "предыдущую" неделю + const isNext = /следующ/i.test(combinedText) || /вперёд/i.test(combinedText) || /next/i.test(combinedText) || /→/i.test(combinedText) + const isPrev = /предыдущ/i.test(combinedText) || /назад/i.test(combinedText) || /prev/i.test(combinedText) || /←/i.test(combinedText) + + if (isNext && currentWeekNumber) { + weekNumber = currentWeekNumber + 1 + } else if (isPrev && currentWeekNumber) { + weekNumber = currentWeekNumber - 1 + } else { + // Если не можем определить, используем текущий номер недели как fallback + weekNumber = currentWeekNumber + } + } + + // Сохраняем связь wk -> weekNumber + if (!wkToWeekNumber.has(wk)) { + wkToWeekNumber.set(wk, weekNumber) + weeks.push({ wk, weekNumber }) + } + } + } + + // Обрабатываем формы + for (const form of forms) { + if (form instanceof HTMLFormElement) { + const action = form.getAttribute('action') || '' + const wkMatch = action.match(/[?&]wk=(\d+)/) + if (wkMatch) { + const wk = Number(wkMatch[1]) + if (!wkToWeekNumber.has(wk)) { + // Пытаемся найти номер недели в форме + const formText = form.textContent?.trim() || '' + const weekNumberMatch = formText.match(/(\d+)\s*недел/i) + const weekNumber = weekNumberMatch ? Number(weekNumberMatch[1]) : currentWeekNumber + wkToWeekNumber.set(wk, weekNumber) weeks.push({ wk, weekNumber }) } } + } else if (form instanceof HTMLInputElement) { + const value = form.value + if (value) { + const wk = Number(value) + if (!isNaN(wk) && !wkToWeekNumber.has(wk)) { + const weekNumber = currentWeekNumber + wkToWeekNumber.set(wk, weekNumber) + weeks.push({ wk, weekNumber }) + } + } + } + } + + // Если currentWk не определен, но нашли недели, пытаемся определить текущую + if (!currentWk && weeks.length > 0) { + // Ищем неделю с weekNumber равным currentWeekNumber + const currentWeekInList = weeks.find(w => w.weekNumber === currentWeekNumber) + if (currentWeekInList) { + // Используем найденную неделю как текущую + currentWk = currentWeekInList.wk + } else { + // Если не нашли точное совпадение, но есть недели с соседними номерами, + // пытаемся определить текущую на основе позиции + const sortedByWeekNumber = [...weeks].sort((a, b) => a.weekNumber - b.weekNumber) + const currentIndex = sortedByWeekNumber.findIndex(w => w.weekNumber === currentWeekNumber) + + if (currentIndex < 0 && sortedByWeekNumber.length > 0) { + // Если текущая неделя не найдена, но есть соседние, вычисляем + const firstWeek = sortedByWeekNumber[0] + if (firstWeek.weekNumber === currentWeekNumber + 1) { + // Первая найденная - следующая, значит текущая должна быть на 1 меньше по wk + // Но мы не знаем разницу, поэтому используем первую найденную как следующую + } else if (firstWeek.weekNumber === currentWeekNumber - 1) { + // Первая найденная - предыдущая, значит текущая должна быть на 1 больше по wk + // Вычисляем текущую неделю + const wkDiff = sortedByWeekNumber.length > 1 + ? sortedByWeekNumber[1].wk - firstWeek.wk + : 1 // Предполагаем разницу в 1 + currentWk = firstWeek.wk + wkDiff + weeks.push({ wk: currentWk, weekNumber: currentWeekNumber }) + } + } + } + } + + // Всегда добавляем текущую неделю, если она еще не добавлена + if (currentWk && !weeks.find(w => w.wk === currentWk)) { + weeks.push({ wk: currentWk, weekNumber: currentWeekNumber }) + } + + // Если нашли только одну соседнюю неделю, пытаемся вычислить другую + if (weeks.length === 1 && currentWk && currentWeekNumber) { + const foundWeek = weeks[0] + + // Если найденная неделя - следующая, пытаемся вычислить предыдущую + if (foundWeek.weekNumber === currentWeekNumber + 1) { + if (!weeks.find(w => w.wk === currentWk)) { + weeks.push({ wk: currentWk, weekNumber: currentWeekNumber }) + } + // Вычисляем wk для предыдущей недели на основе разницы + const wkDiff = foundWeek.wk - currentWk + if (wkDiff !== 0) { + const estimatedPrevWk = currentWk - wkDiff + if (estimatedPrevWk > 0 && !weeks.find(w => w.wk === estimatedPrevWk)) { + weeks.push({ wk: estimatedPrevWk, weekNumber: currentWeekNumber - 1 }) + } + } + } + // Если найденная неделя - предыдущая, пытаемся вычислить следующую + else if (foundWeek.weekNumber === currentWeekNumber - 1) { + if (!weeks.find(w => w.wk === currentWk)) { + weeks.push({ wk: currentWk, weekNumber: currentWeekNumber }) + } + // Вычисляем wk для следующей недели на основе разницы + const wkDiff = currentWk - foundWeek.wk + if (wkDiff !== 0) { + const estimatedNextWk = currentWk + wkDiff + if (estimatedNextWk > 0 && !weeks.find(w => w.wk === estimatedNextWk)) { + weeks.push({ wk: estimatedNextWk, weekNumber: currentWeekNumber + 1 }) + } + } + } + // Если это текущая неделя, пытаемся найти соседние + else if (foundWeek.wk === currentWk) { + // Уже есть текущая неделя, ничего не делаем + } + } + + // Если нашли несколько недель, но нет текущей, добавляем её + if (weeks.length > 0 && currentWk && !weeks.find(w => w.wk === currentWk)) { + weeks.push({ wk: currentWk, weekNumber: currentWeekNumber }) + } + + // Если нашли недели, но не можем определить их weekNumber точно, + // пытаемся вычислить на основе разницы в wk + if (weeks.length > 1 && currentWk && currentWeekNumber) { + const currentWeekInList = weeks.find(w => w.wk === currentWk) + if (currentWeekInList) { + // Сортируем по wk и пытаемся определить weekNumber для недель без него + const sortedByWk = [...weeks].sort((a, b) => a.wk - b.wk) + const currentIndex = sortedByWk.findIndex(w => w.wk === currentWk) + + if (currentIndex >= 0) { + for (let i = 0; i < sortedByWk.length; i++) { + const week = sortedByWk[i] + const weekInResult = weeks.find(w => w.wk === week.wk) + if (weekInResult && weekInResult.weekNumber === currentWeekNumber) { + // Если weekNumber совпадает с текущим, но это не текущая неделя, + // пересчитываем на основе позиции + const diff = i - currentIndex + weekInResult.weekNumber = currentWeekNumber + diff + } + } + } } } return weeks.sort((a, b) => a.weekNumber - b.weekNumber) } -function parseLesson(row: Element, isTeacherSchedule: boolean): Lesson | null { - try { - const cells = Array.from(row.querySelectorAll(':scope > td')) - if (cells.length < 4) return null - - const timeText = cells[1].textContent?.trim() || '' - const timeMatch = timeText.match(/(\d{1,2}:\d{2})\s*–\s*(\d{1,2}:\d{2})/) - if (!timeMatch) return null - - const lessonType = cells[2].textContent?.trim() || '' - const contentCell = cells[3] - - let subject = '' - let teacher = '' - let location = '' - let roomText = '' - - if (isTeacherSchedule) { - subject = contentCell.childNodes[0]?.textContent?.trim() || '' - const groupMatch = contentCell.textContent?.match(/Группа:\s*(.+)/) - if (groupMatch) { - teacher = groupMatch[1].trim() - } - } else { - const lines = Array.from(contentCell.childNodes) - .map(node => node.textContent?.trim()) - .filter(text => text) - - subject = lines[0] || '' - teacher = lines[1] || '' - - const fontElement = contentCell.querySelector('font') - if (fontElement) { - const fontText = fontElement.textContent?.trim() || '' - const parts = fontText.split(',') - location = parts[0]?.trim() || '' - roomText = parts[1]?.trim() || '' - } - } - - const lesson: Lesson = { - time: { - start: timeMatch[1], - end: timeMatch[2], - }, - type: lessonType, - topic: cells[4]?.textContent?.trim() || '', - resources: [], - homework: cells[6]?.textContent?.trim() || '', - subject: subject || '', - teacher: teacher || '', - } - - if (location || roomText) { - (lesson as any).place = { - address: location || '', - classroom: roomText || '', - } - } - - // Ресурсы - if (cells[5]) { - Array.from(cells[5].querySelectorAll('a')).forEach(a => { - const title = a.textContent?.trim() - const url = a.getAttribute('href') - if (title && url) { - lesson.resources.push({ type: 'link', title, url }) - } - }) - } - - return lesson - } catch (e) { - console.error('Error parsing lesson', e) - return null - } -} - +// Парсер расписания групп (mn=2). +// Идет по строкам основной таблицы расписания и ищет заголовки дней (

Понедельник 02.03.2026 / 8 неделя

), +// а затем парсит строки с парами, опираясь на уже существующий parseLesson. function parseGroupSchedule( document: Document, groupName: string, @@ -176,31 +266,50 @@ function parseGroupSchedule( shouldParseWeekNavigation: boolean = true ): ParseResult { const tables = Array.from(document.querySelectorAll('table')) + + // Находим таблицу, в которой есть название группы и заголовок "Дисциплина, преподаватель" const table = tables.find((t) => { const text = t.textContent || '' return text.includes(groupName) && text.includes('Дисциплина, преподаватель') }) - if (!table) throw new Error(`Table not found for group ${groupName}`) + if (!table) { + logDebug('parseGroupSchedule: table not found', { groupName, tablesCount: tables.length }) + throw new Error(`Table not found for group ${groupName}`) + } const allRows = Array.from(table.querySelectorAll('tr')) + const days: Day[] = [] - let dayInfo: Partial = {} + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + let dayInfo: Day = {} let dayLessons: Lesson[] = [] let currentWeekNumber: number | undefined for (const row of allRows) { const rowText = row.textContent?.trim() || '' - if (!rowText) continue + if (!rowText) { + continue + } + const looksLikeTableHeader = /№ пары|Время занятий|Дисциплина, преподаватель/i.test(rowText) const h3Element = row.querySelector('h3') const rawTitle = h3Element?.textContent?.trim() || '' - const isDayTitleRow = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя/i.test(rawTitle) + const isDayTitleRow = + /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя/i.test( + rawTitle + ) + // Заголовок дня if (isDayTitleRow) { - if (dayInfo.date && dayLessons.length > 0) { - days.push({ ...dayInfo as Day, lessons: dayLessons }) + // Сохраняем предыдущий день только если в нем есть пары, + // иначе получаются дубликаты заголовков без занятий. + if ('date' in dayInfo && dayLessons.length > 0) { + days.push({ ...dayInfo, lessons: dayLessons }) dayLessons = [] + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore dayInfo = {} } @@ -208,111 +317,730 @@ function parseGroupSchedule( const { date, weekNumber } = dayTitleParser(rawTitle) dayInfo.date = date dayInfo.weekNumber = weekNumber - if (!currentWeekNumber) currentWeekNumber = weekNumber + if (!currentWeekNumber) { + currentWeekNumber = weekNumber + } } catch (e) { - logDebug('parseGroupSchedule: error', { rawTitle, e }) + logDebug('parseGroupSchedule: failed to parse day title', { rawTitle, error: String(e) }) } + + continue + } + + // Пропускаем строку заголовков таблицы + if (looksLikeTableHeader) { continue } const cells = Array.from(row.querySelectorAll(':scope > td')) - if (cells.length > 0 && /^\d+$/.test(cells[0].textContent?.trim() || '')) { - if (dayInfo.date) { - const lesson = parseLesson(row, false) - if (lesson) dayLessons.push(lesson) + if (cells.length === 0) continue + + const firstCellText = cells[0].textContent?.trim() || '' + + // Строка пары: первая ячейка — номер (цифра) + if (/^\d+$/.test(firstCellText)) { + const hasDayContext = 'date' in dayInfo + if (!hasDayContext) { + // На всякий случай логируем, но не падаем + logDebug('parseGroupSchedule: lesson row without day context', { + rowPreview: rowText.substring(0, 100), + }) + continue + } + + const lesson = parseLesson(row, false) + if (lesson) { + dayLessons.push(lesson) + } else { + logDebug('parseGroupSchedule: failed to parse lesson', { + rowPreview: rowText.substring(0, 120), + }) } } } - if (dayInfo.date && dayLessons.length > 0) { - days.push({ ...dayInfo as Day, lessons: dayLessons }) + // Добавляем последний день + if ('date' in dayInfo && dayLessons.length > 0) { + days.push({ ...dayInfo, lessons: dayLessons }) } - const currentUrl = url || (typeof document !== 'undefined' ? document.location?.href : '') || '' + // Извлекаем wk из URL + const currentUrl = url || document.location?.href || '' const wkMatch = currentUrl.match(/[?&]wk=(\d+)/) let currentWk = wkMatch ? Number(wkMatch[1]) : undefined + let availableWeeks: WeekInfo[] | undefined if (shouldParseWeekNavigation && currentWeekNumber) { availableWeeks = parseWeekNavigation(document, currentWeekNumber, currentWk) + if (availableWeeks.length === 0 && currentWk) { availableWeeks.push({ wk: currentWk, weekNumber: currentWeekNumber }) } + if (!currentWk && availableWeeks.length > 0) { - const found = availableWeeks.find(w => w.weekNumber === currentWeekNumber) - currentWk = found ? found.wk : availableWeeks[0].wk + const currentWeekInList = availableWeeks.find((w) => w.weekNumber === currentWeekNumber) + if (currentWeekInList) { + currentWk = currentWeekInList.wk + } else { + currentWk = availableWeeks[0].wk + } } } - return { days, currentWk, availableWeeks } + return { + days, + currentWk, + availableWeeks, + } } +// Специальный парсер для страницы расписания преподавателя (mn=3), +// максимально повторяющий логику python‑парсера из `py-teacher/app.py`. function parseTeacherSchedule( document: Document, url?: string, shouldParseWeekNavigation: boolean = true ): ParseResult { - const tables = Array.from(document.querySelectorAll('table')) - const table = tables.find(t => { - const text = t.textContent || '' - return /Понедельник|Вторник|Среда|Четверг|Пятница|Суббота/.test(text) && text.includes('Дисциплина, преподаватель') - }) + const dayAnchors = Array.from(document.querySelectorAll('a.t_wth')) - if (!table) throw new Error('Teacher schedule table not found') - - const allRows = Array.from(table.querySelectorAll('tr')) const days: Day[] = [] - let dayInfo: Partial = {} - let dayLessons: Lesson[] = [] let currentWeekNumber: number | undefined - for (const row of allRows) { - const h3Element = row.querySelector('h3') - const rawTitle = h3Element?.textContent?.trim() || '' - const isDayTitleRow = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя/i.test(rawTitle) - - if (isDayTitleRow) { - if (dayInfo.date && dayLessons.length > 0) { - days.push({ ...dayInfo as Day, lessons: dayLessons }) - dayLessons = [] - dayInfo = {} - } - try { - const { date, weekNumber } = dayTitleParser(rawTitle) - dayInfo.date = date - dayInfo.weekNumber = weekNumber - if (!currentWeekNumber) currentWeekNumber = weekNumber - } catch (e) {} + for (const anchor of dayAnchors) { + const dayText = anchor.textContent?.trim() || '' + // Пример: "Понедельник 02.03.2026/8 неделя" + const m = dayText.match(/^(\S+)\s*(\d{2}\.\d{2}\.\d{4})\/(\d+)\s+неделя/i) + if (!m) { continue } - const cells = Array.from(row.querySelectorAll(':scope > td')) - if (cells.length > 0 && /^\d+$/.test(cells[0].textContent?.trim() || '')) { - if (dayInfo.date) { - const lesson = parseLesson(row, true) - if (lesson) dayLessons.push(lesson) + const [, , dateStr, weekNumStr] = m + + const [day, month, year] = dateStr.split('.').map(Number) + const date = new Date(year, month - 1, day, 12) + const weekNumber = Number(weekNumStr) + + if (!currentWeekNumber) { + currentWeekNumber = weekNumber + } + + // Ищем родительскую таблицу с парами (cellpadding="1") + let parent: Element | null = anchor as Element + for (let i = 0; i < 10 && parent; i++) { + parent = parent.parentElement + if (parent && parent.tagName === 'TABLE' && parent.getAttribute('cellpadding') === '1') { + break } } + + const lessons: Lesson[] = [] + + if (parent && parent.tagName === 'TABLE') { + const rows = Array.from(parent.querySelectorAll(':scope > tbody > tr, :scope > tr')) + for (const row of rows) { + const cells = Array.from(row.querySelectorAll(':scope > td')) + if (cells.length !== 4) continue + + const numText = cells[0].textContent?.trim() || '' + if (!/^\d+$/.test(numText)) continue + + const timeText = cells[1].textContent?.trim() || '' + if (!timeText) continue + const [startTimeRaw, endTimeRaw] = timeText.split('–') + const startTime = (startTimeRaw || '').trim() + const endTime = (endTimeRaw || '').trim() + + const subjCell = cells[2] + const roomText = cells[3].textContent?.trim() || '' + + // Извлекаем предмет, аудиторию и тип занятия по логике python‑парсера + let subject = '' + let group = '' + let groupShort = '' + let lessonType = '' + let location = '' + + const bold = subjCell.querySelector('b') + if (bold) { + subject = bold.textContent?.trim() || '' + } + + const fontGreen = subjCell.querySelector('font.t_green_10') + if (fontGreen) { + location = fontGreen.textContent?.trim() || '' + } + + // Всё, что идёт после до , это строка с группой и типом занятия + let raw = '' + if (bold) { + let node: ChildNode | null = bold.nextSibling + while (node) { + const nodeType = (node as any).nodeType + // 1 — Element, 3 — Text в DOM API + if (nodeType === 1) { + const el = node as Element + if (el.tagName === 'FONT') { + break + } + if (el.tagName === 'BR') { + node = el.nextSibling + continue + } + raw += el.textContent?.trim() || '' + } else if (nodeType === 3) { + raw += (node.textContent || '').trim() + } + node = node.nextSibling + } + } + + raw = raw.trim() + + if (raw) { + group = raw + const mGrp = raw.match(/\(([^)]+)\)/) + if (mGrp) { + groupShort = mGrp[1] + } + + const idx = raw.indexOf(')') + const after = idx >= 0 ? raw.slice(idx + 1).trim() : '' + if (after) { + const unwrapped = after.replace(/^\((.+)\)$/, '$1').trim() + const inner = unwrapped.match(/\(([^()]+)\)\s*$/) + lessonType = inner ? inner[1] : unwrapped + } + } + + const lesson: Lesson = { + time: { + start: startTime || '', + end: endTime || '', + }, + type: lessonType, + topic: '', + resources: [], + homework: '', + subject: subject || groupShort || group || roomText, + } + + // Если нет предмета и группы, это пустая пара + if (!subject && !groupShort && !group) continue + + if (location || roomText) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error — расширяем union тип за счет наличия place + lesson.place = { + address: location || '', + classroom: roomText || '', + } + } + + lessons.push(lesson) + } + } + + days.push({ + date, + weekNumber, + lessons, + }) } - if (dayInfo.date && dayLessons.length > 0) { - days.push({ ...dayInfo as Day, lessons: dayLessons }) - } + // Фильтруем пустые дни для преподавателей + const filteredDays = days.filter(day => day.lessons.length > 0) - const currentUrl = url || (typeof document !== 'undefined' ? document.location?.href : '') || '' + // Извлекаем wk из URL + const currentUrl = url || document.location?.href || '' const wkMatch = currentUrl.match(/[?&]wk=(\d+)/) let currentWk = wkMatch ? Number(wkMatch[1]) : undefined + let availableWeeks: WeekInfo[] | undefined if (shouldParseWeekNavigation && currentWeekNumber) { availableWeeks = parseWeekNavigation(document, currentWeekNumber, currentWk) + + if (availableWeeks.length === 0 && currentWk) { + availableWeeks.push({ wk: currentWk, weekNumber: currentWeekNumber }) + } + if (!currentWk && availableWeeks.length > 0) { - const found = availableWeeks.find(w => w.weekNumber === currentWeekNumber) - currentWk = found ? found.wk : availableWeeks[0].wk + const currentWeekInList = availableWeeks.find(w => w.weekNumber === currentWeekNumber) + if (currentWeekInList) { + currentWk = currentWeekInList.wk + } else { + currentWk = availableWeeks[0].wk + } } } - return { days, currentWk, availableWeeks } + return { + days: filteredDays, + currentWk, + availableWeeks, + } +} + +const parseLesson = (row: Element, isTeacherSchedule: boolean = false): Lesson | null => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const lesson: LessonObject = {} + + try { + const cells = Array.from(row.querySelectorAll(':scope > td')) + + 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 (disciplineCell && !lesson.isChange && disciplineCell.textContent?.trim() === 'Свободное время') { + return null + } + + // Проверяем наличие ячейки времени + 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 { + if (!disciplineCell) { + throw new Error('Discipline cell not found') + } + + 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 && + (cellText.includes('Свободное время') && cellText.includes('Замена') && cellText.includes('на:')) + + // Проверяем, является ли это заменой предмета на предмет + const isSubjectReplacement = lesson.isChange && + !isFreeTimeReplacement && + cellText.includes('Замена') && + cellText.includes('на:') + + if (isFreeTimeReplacement) { + // Для замены "свободное время" на пару нужно парсить данные после "на:" + // Структура: "Замена Свободное время на:
название
преподаватель адрес
кабинет
+ + // Используем HTML парсинг для извлечения данных после "на:" + const afterOnIndex = cellHTML.indexOf('на:') + if (afterOnIndex !== -1) { + const afterOn = cellHTML.substring(afterOnIndex + 3) // +3 для "на:" + + // Пропускаем первый
(он идет сразу после "на:") + const firstBrIndex = afterOn.indexOf(' тега + const firstBrEnd = afterOn.indexOf('>', firstBrIndex) + 1 + const afterFirstBr = afterOn.substring(firstBrEnd) + + // Извлекаем название предмета (текст до следующего
) + const secondBrIndex = afterFirstBr.indexOf(']+>/g, '').trim() + + // Извлекаем преподавателя (текст между вторым
и или следующим
) + const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1 + const afterSecondBr = afterFirstBr.substring(secondBrEnd) + + if (!isTeacherSchedule) { + const fontIndex = afterSecondBr.indexOf(']+>/g, '').trim() + } else { + // Если нет , преподаватель может быть до следующего
или до конца + const thirdBrIndex = afterSecondBr.indexOf(']+>/g, '').trim() + } else { + lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim() + } + } + } + } else { + // Если нет второго
, название предмета может быть до или до конца + const fontIndex = afterFirstBr.indexOf(']+>/g, '').trim() + } else { + lesson.subject = afterFirstBr.replace(/<[^>]+>/g, '').trim() + } + } + } + + // Ищем адрес и кабинет внутри + const fontMatch = afterOn.match(/]*>([\s\S]*?)<\/font>/i) + if (fontMatch) { + const fontContent = fontMatch[1] + // Ищем паттерн:
адрес
Кабинет: номер + // Сначала убираем все теги и разбиваем по
+ const cleanContent = fontContent.replace(/<[^>]+>/g, '|').split('|').filter((p: string) => p.trim()) + // Ищем адрес (первая непустая часть) и кабинет (часть с "Кабинет:") + for (let i = 0; i < cleanContent.length; i++) { + const part = cleanContent[i].trim() + if (part && !part.includes('Кабинет:')) { + const nextPart = cleanContent[i + 1]?.trim() || '' + const classroomMatch = nextPart.match(/Кабинет:\s*([^\s]+)/i) + if (classroomMatch) { + lesson.place = { + address: part, + classroom: classroomMatch[1] + } + break + } + } + } + } else { + // Если нет , ищем адрес и кабинет напрямую в тексте после "на:" + const addressMatch = afterOn.match(/([^<]+?)(?:]*>|\s+)Кабинет:\s*([^<\s]+)/i) + if (addressMatch) { + lesson.place = { + address: addressMatch[1].replace(/<[^>]+>/g, '').trim(), + classroom: addressMatch[2].trim() + } + } + } + } + } else if (isSubjectReplacement) { + // Для замены предмета на предмет нужно парсить данные после "на:" + // Структура: "Замена [старый предмет] на:
[новый предмет]
[преподаватель] [адрес]
Кабинет: [номер]
+ + // Используем HTML парсинг для извлечения данных после "на:" + const afterOnIndex = cellHTML.indexOf('на:') + if (afterOnIndex !== -1) { + const afterOn = cellHTML.substring(afterOnIndex + 3) // +3 для "на:" + + // Пропускаем первый
(он идет сразу после "на:") + const firstBrIndex = afterOn.indexOf(' тега + const firstBrEnd = afterOn.indexOf('>', firstBrIndex) + 1 + const afterFirstBr = afterOn.substring(firstBrEnd) + + // Извлекаем название предмета (текст до следующего
) + const secondBrIndex = afterFirstBr.indexOf(']+>/g, '').trim() + + // Извлекаем преподавателя (текст между вторым
и или следующим
) + const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1 + const afterSecondBr = afterFirstBr.substring(secondBrEnd) + + if (!isTeacherSchedule) { + const fontIndex = afterSecondBr.indexOf(']+>/g, '').trim() + } else { + // Если нет , преподаватель может быть до следующего
или до конца + const thirdBrIndex = afterSecondBr.indexOf(']+>/g, '').trim() + } else { + lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim() + } + } + } + } else { + // Если нет второго
, название предмета может быть до или до конца + const fontIndex = afterFirstBr.indexOf(']+>/g, '').trim() + } else { + lesson.subject = afterFirstBr.replace(/<[^>]+>/g, '').trim() + } + } + } + + // Ищем адрес и кабинет внутри + const fontMatch = afterOn.match(/]*>([\s\S]*?)<\/font>/i) + if (fontMatch) { + const fontContent = fontMatch[1] + // Ищем паттерн:
адрес
Кабинет: номер + // Сначала убираем все теги и разбиваем по
+ const cleanContent = fontContent.replace(/<[^>]+>/g, '|').split('|').filter((p: string) => p.trim()) + // Ищем адрес (первая непустая часть) и кабинет (часть с "Кабинет:") + for (let i = 0; i < cleanContent.length; i++) { + const part = cleanContent[i].trim() + if (part && !part.includes('Кабинет:')) { + const nextPart = cleanContent[i + 1]?.trim() || '' + const classroomMatch = nextPart.match(/Кабинет:\s*([^\s]+)/i) + if (classroomMatch) { + lesson.place = { + address: part, + classroom: classroomMatch[1] + } + break + } + } + } + } else { + // Если нет , ищем адрес и кабинет напрямую в тексте после "на:" + const addressMatch = afterOn.match(/([^<]+?)(?:]*>|\s+)Кабинет:\s*([^<\s]+)/i) + if (addressMatch) { + lesson.place = { + address: addressMatch[1].replace(/<[^>]+>/g, '').trim(), + classroom: addressMatch[2].trim() + } + } + } + } + } else { + // Обычный парсинг для нормальных пар + // Для преподавателей структура может отличаться - пробуем разные варианты + let subjectText = '' + + // Вариант 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) { + const addressNode = placeCell.childNodes[1] + const classroomNode = placeCell.childNodes[3] + + if (addressNode && classroomNode) { + const address = addressNode.textContent?.trim() + const classroomText = classroomNode.textContent?.trim() + const classroomMatch = classroomText?.match(/^Кабинет: ([^ ]+)(-2)?$/) + + if (address && classroomMatch) { + lesson.place = { + address, + classroom: classroomMatch[1] + } + } + } + } else if (isTeacherSchedule) { + // Для преподавателей место может быть в другом формате в тексте ячейки + // Формат: "ПредметГруппа(Аудитория)Адрес" или в отдельной ячейке + // Сначала проверяем наличие отдельной ячейки с местом (как для групп) + const placeCellIndex = cells.length >= 6 ? 5 : (cells.length >= 5 ? 4 : -1) + if (placeCellIndex >= 0 && cells[placeCellIndex]) { + const placeCell = cells[placeCellIndex] + const placeText = placeCell.textContent?.trim() || '' + // Ищем адрес и кабинет в формате "адрес\nКабинет: номер" + const placeMatch = placeText.match(/([^\n]+)\n.*?Кабинет:\s*([^\s\n]+)/i) + if (placeMatch) { + lesson.place = { + address: placeMatch[1].trim(), + classroom: placeMatch[2].trim() + } + } + } + + // Если не нашли в отдельной ячейке, ищем в тексте ячейки с предметом + if (!lesson.place) { + 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 && address.length > 3) { + lesson.place = { + address, + classroom + } + } + } + } + } + } + } + } catch(e) { + console.error('Error while parsing discipline', e, cells[3]?.textContent?.trim()) + lesson.fallbackDiscipline = cells[3]?.textContent?.trim() + } + + if (cells[4]) { + lesson.topic = cells[4].textContent?.trim() || '' + } + + // Колонка "Ресурс" + lesson.resources = [] + if (cells[5]) { + Array.from(cells[5].querySelectorAll('a')).forEach(a => { + const title = a.textContent?.trim() + const url = a.getAttribute('href') + if (title && url) { + lesson.resources.push({ + type: 'link', + title, + url, + }) + } + }) + } + + // Колонка "Задание для выполнения" + lesson.homework = '' + if (cells[6]) { + const hwCell = cells[6] + const rawText = hwCell.textContent?.replace(/\s+/g, ' ').trim() || '' + if (rawText) { + lesson.homework = rawText + } + + // Добавляем ссылки из задания тоже в список материалов + Array.from(hwCell.querySelectorAll('a')).forEach(a => { + const title = a.textContent?.trim() + const url = a.getAttribute('href') + if (title && url) { + lesson.resources.push({ + type: 'link', + title, + url, + }) + } + }) + } + + return lesson + } catch(e) { + console.error('Error while parsing lesson in table', e, row.textContent?.trim()) + return null + } } export function parsePage( @@ -322,8 +1050,318 @@ export function parsePage( shouldParseWeekNavigation: boolean = true, isTeacherSchedule: boolean = false ): ParseResult { + // Для расписания преподавателей используем отдельный, более надежный парсер, + // основанный на уже отлаженной python‑версии. if (isTeacherSchedule) { return parseTeacherSchedule(document, url, shouldParseWeekNavigation) } + + // Для расписания групп используем отдельный парсер, который опирается на структуру + // таблицы с заголовками дней (

Понедельник 02.03.2026 / 8 неделя

) + // и строки с номерами пар. return parseGroupSchedule(document, groupName, url, shouldParseWeekNavigation) -} + + // Ищем все таблицы на странице, а не только прямых потомков body. + // На сайте колледжа разметка может меняться (таблицу расписания могут оборачивать в
,
и т.п.), + // поэтому ограничение 'body > table' ломало парсинг, когда структура слегка поменялась. + const tables = Array.from(document.querySelectorAll('table')) + + // Пытаемся найти таблицу разными способами + 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') || table.querySelector(':scope > tr:first-child') + const firstRowText = firstRow?.textContent?.trim() || '' + // Проверяем точное совпадение + return firstRowText === groupName + }) + } + + // Способ 2.5: Ищем таблицу, которая содержит имя где-то в первых строках (только если имя длинное) + if (!table && groupName.length > 10) { + table = tables.find(table => { + const rows = Array.from(table.querySelectorAll('tr')).slice(0, 3) + return rows.some(row => { + const rowText = row.textContent?.trim() || '' + return rowText.includes(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) { + logDebug('parsePage: tables analyzing', { groupName, tablesCount: tables.length }) + 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 + logDebug('parsePage: table analysis', { tableIndex: i, rows: t.querySelectorAll('tr').length, hasDayTitles, hasTimeSlots, nameCount, preview: text.substring(0, 80) }) + }) + throw new Error(`Table not found for ${groupName}. Found ${tables.length} tables on the page.`) + } + + logDebug('parsePage: selected table', { groupName, rows: table.querySelectorAll('tr').length }) + + // Пытаемся найти 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}`) + } + } + + // Структура таблицы расписания с lk.ks.psuti.ru (mn=2&obj=ID группы): + // allRows[0] — название группы в одной ячейке (colspan=7); + // allRows[1] — пустая строка-разделитель (одна td colspan=7); + // далее повторяются блоки: [заголовок дня] [заголовок колонок] [пары...] [пустая строка]. + // Заголовок дня: одна с одной , внутри вложенная таблица с

Понедельник DD.MM.YYYY / N неделя

. + // Заголовок колонок: с 7 — «№ пары», «Время занятий», «Способ», «Дисциплина, преподаватель», «Тема занятия», «Ресурс», «Задание для выполнения». + // Строка пары: 7 — номер, время (08:00 – 09:30), способ, ячейка с предметом/преподавателем/местом (subject +
+ teacher + адрес, Кабинет), тема, ресурсы, задание. + const allRows = tbody + ? Array.from(tbody.querySelectorAll('tr')) + : Array.from(table.querySelectorAll('tr')) + + const rows = allRows.slice(2) + logDebug('parsePage: rows to parse', { groupName, rowsCount: rows.length, firstRows: rows.slice(0, 5).map(r => r.textContent?.trim().substring(0, 50)) }) + + const days = [] + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + let dayInfo: Day = {} + let dayLessons: Lesson[] = [] + let previousRowIsDayTitle = false + let currentWeekNumber: number | undefined + + // Пытаемся извлечь текущий wk из URL + const currentUrl = url || document.location?.href || '' + const wkMatch = currentUrl.match(/[?&]wk=(\d+)/) + const currentWk = wkMatch ? Number(wkMatch[1]) : undefined + + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + const rowText = row.textContent?.trim() || '' + + const isDivider = rowText === '' + // Строка заголовка таблицы (идёт сразу после заголовка дня) — не считать новым днём + const looksLikeTableHeader = /№ пары|Время занятий|Дисциплина, преподаватель/i.test(rowText) + // Проверяем, является ли строка заголовком дня: должна содержать паттерн "день недели дата / номер неделя" + const looksLikeDayTitle = /(Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя/i.test(rowText) + const isDayTitle = looksLikeDayTitle && !looksLikeTableHeader + // Если уже есть день с датой и встречаем новый заголовок дня, сохраняем предыдущий день + const isNewDayTitle = isDayTitle && ('date' in dayInfo) + const isTableHeader = previousRowIsDayTitle + + // Если встречаем новый день, сохраняем предыдущий + 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 && dayLessons.length > 0) { + 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 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) + logDebug('parsePage: parsed day title', { dayTitleText, date, weekNumber }) + dayInfo.date = date + dayInfo.weekNumber = weekNumber + if (!currentWeekNumber) { + currentWeekNumber = weekNumber + } + previousRowIsDayTitle = true + // Важно: после парсинга заголовка дня, следующий цикл должен обрабатывать уроки + // Поэтому НЕ делаем continue, а просто устанавливаем флаг + // Проверяем, что dayInfo действительно установлен + logDebug('parsePage: day info set', { date: dayInfo.date, weekNumber: dayInfo.weekNumber }) + } catch (error) { + // Если не удалось распарсить заголовок, пропускаем строку + logDebug('parsePage: failed to parse day title', { dayTitleText, error: String(error) }) + continue + } + } else { + // Пытаемся распарсить как урок, только если уже есть день + const hasDayContext = 'date' in dayInfo + if (hasDayContext) { + // Сразу пропускаем строку заголовка таблицы (№ пары, Время занятий, …) + if (looksLikeTableHeader) { + previousRowIsDayTitle = false + continue + } + // Пропускаем строки, которые являются только номерами пар или временем (заголовки столбцов) + 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 + } + logDebug('parsePage: parsed lesson', { lessonName }) + dayLessons.push(lesson) + } else { + // Логируем строки, которые не распарсились как уроки + logDebug('parsePage: failed to parse lesson from row', { rowPreview: rowText.substring(0, 100) }) + } + } else { + // Логируем строки, которые не распознаются как дни и не парсятся как уроки + // Но только если это не пустая строка и не заголовок дня + if (rowText && !looksLikeDayTitle) { + const cells = Array.from(row.querySelectorAll(':scope > td')) + if (cells.length > 0) { + logDebug('parsePage: skipping row (no day context)', { rowPreview: rowText.substring(0, 100) }) + } + } + } + } + } + + // Добавляем последний день, если он не был добавлен + if ('date' in dayInfo) { + logDebug('parsePage: adding final day', { lessonsCount: dayLessons.length }) + days.push({ ...dayInfo, lessons: dayLessons }) + } + + logDebug('parsePage: total days parsed', { daysCount: days.length }) + + // Парсим навигацию по неделям только если включена навигация + let availableWeeks: WeekInfo[] | undefined + let finalCurrentWk = currentWk + + if (shouldParseWeekNavigation && currentWeekNumber) { + availableWeeks = parseWeekNavigation(document, currentWeekNumber, currentWk) + + // Если не нашли ссылки, но есть текущий wk, добавляем текущую неделю + if (availableWeeks.length === 0 && currentWk) { + availableWeeks.push({ wk: currentWk, weekNumber: currentWeekNumber }) + } + + // Если currentWk не определен, но нашли недели, пытаемся определить текущую + if (!currentWk && availableWeeks.length > 0) { + // Ищем неделю с weekNumber равным currentWeekNumber + const currentWeekInList = availableWeeks.find(w => w.weekNumber === currentWeekNumber) + if (currentWeekInList) { + finalCurrentWk = currentWeekInList.wk + } else { + // Если не нашли точное совпадение, берем первую неделю как текущую + finalCurrentWk = availableWeeks[0].wk + } + } + } + + return { + days, + currentWk: finalCurrentWk, + availableWeeks + } +} \ No newline at end of file