fix(parser): fix teacher and group schedule
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user