fix(parser): fix teacher and group schedule

This commit is contained in:
kilyabin
2026-03-02 13:19:15 +04:00
parent b9ae52681e
commit 9bca838fbc

View File

@@ -256,6 +256,326 @@ function parseWeekNavigation(document: Document, currentWeekNumber: number, curr
return weeks.sort((a, b) => a.weekNumber - b.weekNumber) 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[] = []
// 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
}
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 })
dayLessons = []
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
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 })
}
// Извлекаем 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
}
}
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() || ''
}
// Всё, что идёт после <b> до <font>, это строка с группой и типом занятия
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 (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,
})
}
// Извлекаем 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,
}
}
const parseLesson = (row: Element, isTeacherSchedule: boolean = false): Lesson | null => { const parseLesson = (row: Element, isTeacherSchedule: boolean = false): Lesson | null => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error
@@ -694,8 +1014,28 @@ const parseLesson = (row: Element, isTeacherSchedule: boolean = false): Lesson |
} }
} }
export function parsePage(document: Document, groupName: string, url?: string, shouldParseWeekNavigation: boolean = true, isTeacherSchedule: boolean = false): ParseResult { export function parsePage(
const tables = Array.from(document.querySelectorAll('body > table')) document: Document,
groupName: string,
url?: string,
shouldParseWeekNavigation: boolean = true,
isTeacherSchedule: boolean = false
): ParseResult {
// Для расписания преподавателей используем отдельный, более надежный парсер,
// основанный на уже отлаженной pythonверсии.
if (isTeacherSchedule) {
return parseTeacherSchedule(document, url, shouldParseWeekNavigation)
}
// Для расписания групп используем отдельный парсер, который опирается на структуру
// таблицы с заголовками дней (<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 let table: Element | undefined