1398 lines
64 KiB
TypeScript
1398 lines
64 KiB
TypeScript
import { Day } from '@/shared/model/day'
|
||
import { Lesson } from '@/shared/model/lesson'
|
||
import { logDebug } from '@/app/logger'
|
||
|
||
export type WeekInfo = {
|
||
wk: number
|
||
weekNumber: number
|
||
}
|
||
|
||
export type ParseResult = {
|
||
days: Day[]
|
||
currentWk?: number
|
||
availableWeeks?: WeekInfo[]
|
||
}
|
||
|
||
const dayTitleParser = (text: string) => {
|
||
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 }
|
||
}
|
||
|
||
/**
|
||
* Парсит ссылки навигации по неделям из HTML страницы
|
||
* Ищет ссылки вида ?mn=2&obj=XXX&wk=YYY и извлекает wk и weekNumber
|
||
*/
|
||
function parseWeekNavigation(document: Document, currentWeekNumber: number, currentWk?: number): WeekInfo[] {
|
||
const weeks: WeekInfo[] = []
|
||
const wkToWeekNumber = new Map<number, number>()
|
||
|
||
// Ищем все ссылки, которые содержат параметр 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<Element>()
|
||
links.forEach(el => allLinkElementsSet.add(el))
|
||
linksWithOnclick.forEach(el => allLinkElementsSet.add(el))
|
||
elementsWithDataHref.forEach(el => allLinkElementsSet.add(el))
|
||
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 || 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)
|
||
let weekNumber = weekNumberMatch ? Number(weekNumberMatch[1]) : undefined
|
||
|
||
// Если не нашли в тексте, пытаемся определить по контексту
|
||
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)
|
||
}
|
||
|
||
// Парсер расписания групп (mn=2).
|
||
// Идет по строкам основной таблицы расписания и ищет заголовки дней (<h3>Понедельник 02.03.2026 / 8 неделя</h3>),
|
||
// а затем парсит строки с парами, опираясь на уже существующий parseLesson.
|
||
function parseGroupSchedule(
|
||
document: Document,
|
||
groupName: string,
|
||
url?: string,
|
||
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) {
|
||
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<Day> = {}
|
||
let dayLessons: Lesson[] = []
|
||
let currentWeekNumber: number | undefined
|
||
|
||
for (const row of allRows) {
|
||
const rowText = row.textContent?.trim() || ''
|
||
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
|
||
)
|
||
|
||
// Заголовок дня
|
||
if (isDayTitleRow) {
|
||
// Сохраняем предыдущий день только если в нем есть пары,
|
||
// иначе получаются дубликаты заголовков без занятий.
|
||
if ('date' in dayInfo && dayLessons.length > 0) {
|
||
days.push({ ...dayInfo, lessons: dayLessons } as Day)
|
||
dayLessons = []
|
||
dayInfo = {}
|
||
}
|
||
|
||
try {
|
||
const { date, weekNumber } = dayTitleParser(rawTitle)
|
||
dayInfo.date = date
|
||
dayInfo.weekNumber = weekNumber
|
||
if (!currentWeekNumber) {
|
||
currentWeekNumber = weekNumber
|
||
}
|
||
} catch (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) 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 ('date' in dayInfo && dayLessons.length > 0) {
|
||
days.push({ ...dayInfo, lessons: dayLessons } as Day)
|
||
}
|
||
|
||
// Извлекаем 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 currentWeekInList = availableWeeks.find((w) => w.weekNumber === currentWeekNumber)
|
||
if (currentWeekInList) {
|
||
currentWk = currentWeekInList.wk
|
||
} else {
|
||
currentWk = availableWeeks[0].wk
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
days,
|
||
currentWk,
|
||
availableWeeks,
|
||
}
|
||
}
|
||
|
||
// Специальный парсер для страницы расписания преподавателя (mn=3),
|
||
// максимально повторяющий логику python‑парсера из `py-teacher/app.py`.
|
||
function parseTeacherSchedule(
|
||
document: Document,
|
||
url?: string,
|
||
shouldParseWeekNavigation: boolean = true
|
||
): ParseResult {
|
||
const dayAnchors = Array.from(document.querySelectorAll('a.t_wth'))
|
||
|
||
const days: Day[] = []
|
||
let currentWeekNumber: number | undefined
|
||
|
||
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 [, , 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
|
||
}
|
||
}
|
||
|
||
// Если не нашли по cellpadding, ищем просто ближайшую таблицу
|
||
if (!parent || parent.tagName !== 'TABLE') {
|
||
parent = anchor.closest('table')
|
||
}
|
||
|
||
// Если все еще не нашли, ищем таблицу рядом с якорем
|
||
if (!parent || parent.tagName !== 'TABLE') {
|
||
// Ищем следующую таблицу после якоря
|
||
let nextSibling: Node | null = anchor as Node
|
||
while (nextSibling) {
|
||
nextSibling = nextSibling.nextSibling
|
||
if (nextSibling && nextSibling.nodeType === 1 && (nextSibling as Element).tagName === 'TABLE') {
|
||
parent = nextSibling as Element
|
||
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]
|
||
// Проверяем наличие ячейки перед доступом к textContent
|
||
const roomText = cells[3]?.textContent?.trim() || ''
|
||
|
||
// Извлекаем предмет, аудиторию и тип занятия по логике python‑парсера
|
||
let subject = ''
|
||
let group = ''
|
||
let groupShort = ''
|
||
let lessonType = ''
|
||
let location = ''
|
||
|
||
// Проверяем наличие subjCell перед поиском элементов
|
||
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() || ''
|
||
}
|
||
|
||
// Всё, что идёт после <b> до <font>, это строка с группой и типом занятия
|
||
let raw = ''
|
||
if (bold && subjCell) {
|
||
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) {
|
||
lesson.place = {
|
||
address: location || '',
|
||
classroom: roomText || '',
|
||
}
|
||
}
|
||
|
||
lessons.push(lesson)
|
||
}
|
||
}
|
||
|
||
days.push({
|
||
date,
|
||
weekNumber,
|
||
lessons,
|
||
})
|
||
}
|
||
|
||
// Фильтруем пустые дни для преподавателей
|
||
const filteredDays = days.filter(day => day.lessons.length > 0)
|
||
|
||
// Извлекаем 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 currentWeekInList = availableWeeks.find(w => w.weekNumber === currentWeekNumber)
|
||
if (currentWeekInList) {
|
||
currentWk = currentWeekInList.wk
|
||
} else {
|
||
currentWk = availableWeeks[0].wk
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
days: filteredDays,
|
||
currentWk,
|
||
availableWeeks,
|
||
}
|
||
}
|
||
|
||
const parseLesson = (row: Element, isTeacherSchedule: boolean = false): Lesson | null => {
|
||
const lesson: Partial<Lesson> & { fallbackDiscipline?: string, teacher?: string, place?: { address: string, classroom: string }, subject?: string } = {
|
||
resources: [],
|
||
homework: '',
|
||
type: '',
|
||
time: { start: '', end: '' }
|
||
}
|
||
|
||
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) {
|
||
// Для замены "свободное время" на пару нужно парсить данные после "на:"
|
||
// Структура: "Замена Свободное время на:</a><br> название <br> преподаватель <font> адрес <br> кабинет </font>
|
||
|
||
// Используем HTML парсинг для извлечения данных после "на:"
|
||
const afterOnIndex = cellHTML.indexOf('на:')
|
||
if (afterOnIndex !== -1) {
|
||
const afterOn = cellHTML.substring(afterOnIndex + 3) // +3 для "на:"
|
||
|
||
// Пропускаем первый <br> (он идет сразу после "на:")
|
||
const firstBrIndex = afterOn.indexOf('<br')
|
||
if (firstBrIndex !== -1) {
|
||
// Находим конец первого <br> тега
|
||
const firstBrEnd = afterOn.indexOf('>', firstBrIndex) + 1
|
||
const afterFirstBr = afterOn.substring(firstBrEnd)
|
||
|
||
// Извлекаем название предмета (текст до следующего <br>)
|
||
const secondBrIndex = afterFirstBr.indexOf('<br')
|
||
if (secondBrIndex !== -1) {
|
||
const subjectHTML = afterFirstBr.substring(0, secondBrIndex)
|
||
lesson.subject = subjectHTML.replace(/<[^>]+>/g, '').trim()
|
||
|
||
// Извлекаем преподавателя (текст между вторым <br> и <font> или следующим <br>)
|
||
const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1
|
||
const afterSecondBr = afterFirstBr.substring(secondBrEnd)
|
||
|
||
if (!isTeacherSchedule) {
|
||
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)
|
||
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
|
||
} else {
|
||
lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim()
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Если нет второго <br>, название предмета может быть до <font> или до конца
|
||
const fontIndex = afterFirstBr.indexOf('<font')
|
||
if (fontIndex !== -1) {
|
||
const subjectHTML = afterFirstBr.substring(0, fontIndex)
|
||
lesson.subject = subjectHTML.replace(/<[^>]+>/g, '').trim()
|
||
} else {
|
||
lesson.subject = afterFirstBr.replace(/<[^>]+>/g, '').trim()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ищем адрес и кабинет внутри <font>
|
||
const fontMatch = afterOn.match(/<font[^>]*>([\s\S]*?)<\/font>/i)
|
||
if (fontMatch) {
|
||
const fontContent = fontMatch[1]
|
||
// Ищем паттерн: <br> адрес <br> Кабинет: номер
|
||
// Сначала убираем все теги и разбиваем по <br>
|
||
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 {
|
||
// Если нет <font>, ищем адрес и кабинет напрямую в тексте после "на:"
|
||
const addressMatch = afterOn.match(/([^<]+?)(?:<br[^>]*>|\s+)Кабинет:\s*([^<\s]+)/i)
|
||
if (addressMatch) {
|
||
lesson.place = {
|
||
address: addressMatch[1].replace(/<[^>]+>/g, '').trim(),
|
||
classroom: addressMatch[2].trim()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (isSubjectReplacement) {
|
||
// Для замены предмета на предмет нужно парсить данные после "на:"
|
||
// Структура: "Замена [старый предмет] на:</a><br> [новый предмет] <br> [преподаватель] <font> [адрес] <br> Кабинет: [номер] </font>
|
||
|
||
// Используем HTML парсинг для извлечения данных после "на:"
|
||
const afterOnIndex = cellHTML.indexOf('на:')
|
||
if (afterOnIndex !== -1) {
|
||
const afterOn = cellHTML.substring(afterOnIndex + 3) // +3 для "на:"
|
||
|
||
// Пропускаем первый <br> (он идет сразу после "на:")
|
||
const firstBrIndex = afterOn.indexOf('<br')
|
||
if (firstBrIndex !== -1) {
|
||
// Находим конец первого <br> тега
|
||
const firstBrEnd = afterOn.indexOf('>', firstBrIndex) + 1
|
||
const afterFirstBr = afterOn.substring(firstBrEnd)
|
||
|
||
// Извлекаем название предмета (текст до следующего <br>)
|
||
const secondBrIndex = afterFirstBr.indexOf('<br')
|
||
if (secondBrIndex !== -1) {
|
||
const subjectHTML = afterFirstBr.substring(0, secondBrIndex)
|
||
lesson.subject = subjectHTML.replace(/<[^>]+>/g, '').trim()
|
||
|
||
// Извлекаем преподавателя (текст между вторым <br> и <font> или следующим <br>)
|
||
const secondBrEnd = afterFirstBr.indexOf('>', secondBrIndex) + 1
|
||
const afterSecondBr = afterFirstBr.substring(secondBrEnd)
|
||
|
||
if (!isTeacherSchedule) {
|
||
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)
|
||
lesson.teacher = teacherHTML.replace(/<[^>]+>/g, '').trim()
|
||
} else {
|
||
lesson.teacher = afterSecondBr.replace(/<[^>]+>/g, '').trim()
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Если нет второго <br>, название предмета может быть до <font> или до конца
|
||
const fontIndex = afterFirstBr.indexOf('<font')
|
||
if (fontIndex !== -1) {
|
||
const subjectHTML = afterFirstBr.substring(0, fontIndex)
|
||
lesson.subject = subjectHTML.replace(/<[^>]+>/g, '').trim()
|
||
} else {
|
||
lesson.subject = afterFirstBr.replace(/<[^>]+>/g, '').trim()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ищем адрес и кабинет внутри <font>
|
||
const fontMatch = afterOn.match(/<font[^>]*>([\s\S]*?)<\/font>/i)
|
||
if (fontMatch) {
|
||
const fontContent = fontMatch[1]
|
||
// Ищем паттерн: <br> адрес <br> Кабинет: номер
|
||
// Сначала убираем все теги и разбиваем по <br>
|
||
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 {
|
||
// Если нет <font>, ищем адрес и кабинет напрямую в тексте после "на:"
|
||
const addressMatch = afterOn.match(/([^<]+?)(?:<br[^>]*>|\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() || ''
|
||
}
|
||
|
||
// Колонка "Ресурс"
|
||
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,
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// Колонка "Задание для выполнения"
|
||
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 as Lesson
|
||
} catch(e) {
|
||
console.error('Error while parsing lesson in table', e, row.textContent?.trim())
|
||
return null
|
||
}
|
||
}
|
||
|
||
export function parsePage(
|
||
document: Document,
|
||
groupName: string,
|
||
url?: string,
|
||
shouldParseWeekNavigation: boolean = true,
|
||
isTeacherSchedule: boolean = false
|
||
): ParseResult {
|
||
// Для расписания преподавателей используем отдельный, более надежный парсер,
|
||
// основанный на уже отлаженной python‑версии.
|
||
if (isTeacherSchedule) {
|
||
try {
|
||
const result = parseTeacherSchedule(document, url, shouldParseWeekNavigation)
|
||
// Если парсер не нашел дней, пробуем fallback на parseGroupSchedule
|
||
if (result.days.length === 0) {
|
||
logDebug('parsePage: parseTeacherSchedule returned no days, trying fallback')
|
||
return parseGroupSchedule(document, groupName, url, shouldParseWeekNavigation)
|
||
}
|
||
return result
|
||
} catch (error) {
|
||
// При ошибке парсинга преподавателя, пробуем fallback
|
||
logDebug('parsePage: parseTeacherSchedule failed, trying fallback', { error })
|
||
try {
|
||
return parseGroupSchedule(document, groupName, url, shouldParseWeekNavigation)
|
||
} catch (fallbackError) {
|
||
// Если и fallback не сработал, выбрасываем оригинальную ошибку
|
||
throw error
|
||
}
|
||
}
|
||
}
|
||
|
||
// Для расписания групп используем отдельный парсер, который опирается на структуру
|
||
// таблицы с заголовками дней (<h3>Понедельник 02.03.2026 / 8 неделя</h3>)
|
||
// и строки с номерами пар.
|
||
return parseGroupSchedule(document, groupName, url, shouldParseWeekNavigation)
|
||
|
||
// Ищем все таблицы на странице, а не только прямых потомков body.
|
||
// На сайте колледжа разметка может меняться (таблицу расписания могут оборачивать в <div>, <center> и т.п.),
|
||
// поэтому ограничение '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.`)
|
||
}
|
||
|
||
// К этому моменту table определен (иначе была бы ошибка выше)
|
||
const selectedTable = table!
|
||
|
||
logDebug('parsePage: selected table', { groupName, rows: selectedTable.querySelectorAll('tr').length })
|
||
|
||
// Пытаемся найти tbody или использовать прямые children таблицы
|
||
let tbody: HTMLTableSectionElement | null = null
|
||
const tbodyElement = selectedTable.querySelector('tbody')
|
||
if (tbodyElement) {
|
||
tbody = tbodyElement as HTMLTableSectionElement
|
||
} else if (selectedTable.children.length > 0 && selectedTable.children[0].tagName === 'TBODY') {
|
||
tbody = selectedTable.children[0] as HTMLTableSectionElement
|
||
}
|
||
|
||
if (!tbody && selectedTable.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);
|
||
// далее повторяются блоки: [заголовок дня] [заголовок колонок] [пары...] [пустая строка].
|
||
// Заголовок дня: одна <tr> с одной <td colspan=7>, внутри вложенная таблица с <h3>Понедельник DD.MM.YYYY / N неделя</h3>.
|
||
// Заголовок колонок: <tr> с 7 <td> — «№ пары», «Время занятий», «Способ», «Дисциплина, преподаватель», «Тема занятия», «Ресурс», «Задание для выполнения».
|
||
// Строка пары: 7 <td> — номер, время (08:00 – 09:30), способ, ячейка с предметом/преподавателем/местом (subject + <br> + teacher + <font> адрес, Кабинет), тема, ресурсы, задание.
|
||
const allRows = tbody
|
||
? Array.from(tbody!.querySelectorAll('tr'))
|
||
: Array.from(selectedTable.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: Day[] = []
|
||
let dayInfo: Partial<Day> = {}
|
||
let dayLessons: Lesson[] = []
|
||
let previousRowIsDayTitle = false
|
||
let currentWeekNumber: number | undefined
|
||
|
||
// Пытаемся извлечь текущий wk из URL
|
||
const currentUrl = url || document.location?.href || ''
|
||
const wkMatchResult = currentUrl.match(/[?&]wk=(\d+)/)
|
||
const wkMatchValue = wkMatchResult?.[1]
|
||
const currentWk = wkMatchValue ? Number(wkMatchValue) : 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 } as Day)
|
||
dayLessons = []
|
||
dayInfo = {}
|
||
previousRowIsDayTitle = false
|
||
}
|
||
|
||
if (isDivider) {
|
||
// Сохраняем день при разделителе только если есть уроки — иначе пустая строка
|
||
// между заголовком дня и строкой «№ пары / Время» сбрасывала контекст и все пары пропускались
|
||
if ('date' in dayInfo && dayLessons.length > 0) {
|
||
days.push({ ...dayInfo, lessons: dayLessons } as Day)
|
||
dayLessons = []
|
||
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 dayTitleMatchResult = dayTitleText.match(/((Понедельник|Вторник|Среда|Четверг|Пятница|Суббота|Воскресенье)\s+\d{1,2}\.\d{1,2}\.\d{4}\s*\/\s*\d+\s+неделя)/i)
|
||
const dayTitleMatchValue = dayTitleMatchResult?.[1]!
|
||
if (dayTitleMatchValue) {
|
||
dayTitleText = dayTitleMatchValue
|
||
}
|
||
|
||
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) {
|
||
let lessonName = 'unknown'
|
||
const lessonAny = lesson as any
|
||
if ('subject' in lessonAny && lessonAny.subject) {
|
||
lessonName = lessonAny.subject
|
||
} else if ('fallbackDiscipline' in lessonAny && lessonAny.fallbackDiscipline) {
|
||
lessonName = lessonAny.fallbackDiscipline
|
||
}
|
||
logDebug('parsePage: parsed lesson', { lessonName })
|
||
dayLessons.push(lesson as 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 } as Day)
|
||
}
|
||
|
||
logDebug('parsePage: total days parsed', { daysCount: days.length })
|
||
|
||
// Парсим навигацию по неделям только если включена навигация
|
||
let availableWeeks: WeekInfo[] = []
|
||
let finalCurrentWk = currentWk
|
||
|
||
if (shouldParseWeekNavigation && currentWeekNumber != null) {
|
||
availableWeeks = parseWeekNavigation(document, currentWeekNumber!, currentWk)
|
||
|
||
// Если не нашли ссылки, но есть текущий wk, добавляем текущую неделю
|
||
if (availableWeeks.length === 0 && currentWk != null) {
|
||
availableWeeks.push({ wk: currentWk!, weekNumber: currentWeekNumber! })
|
||
}
|
||
|
||
// Если currentWk не определен, но нашли недели, пытаемся определить текущую
|
||
if (currentWk == null && 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
|
||
}
|
||
} |