Files
vyatsu-schedule/app/src/main/java/agzam/vyatsu/data/remote/TeacherScheduleParser.kt
T

160 lines
7.4 KiB
Kotlin
Raw Normal View History

package agzam.vyatsu.data.remote
import agzam.vyatsu.data.remote.pdf.TextPatterns
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.TimeSlot
import android.util.Log
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
object TeacherScheduleParser {
private const val TAG = "TeacherParserDebug"
private val DAY_REGEX = Regex("""^(понедельник|вторник|среда|четверг|пятница|суббота|воскресенье)\s+(\d{2}\.\d{2}\.\d{2})$""")
private val INTERVAL_REGEX = Regex("""^(\d{2}:\d{2})-(\d{2}:\d{2})$""")
private val GROUP_TOKEN = Regex("""(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}])""")
private val SPLIT_BY_GROUP = Regex("""(?=(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}]))""")
private val DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yy")
data class ParsedTeacherSchedule(val teachers: List<String>, val days: List<Day>)
2026-10-08 01:42:26 +03:00
/**
* Столбец расписания преподавателя: имя и абсолютный индекс ячейки в шапке.
* Индекс нужен для точного выравнивания с рядами данных — в шапке могут
* присутствовать пустые колонки-разделители, которые не должны сдвигать
* соответствие «преподаватель → столбец».
*/
private data class Column(val teacher: String, val headerIndex: Int)
fun parse(html: String): ParsedTeacherSchedule {
val doc = Jsoup.parse(html)
var headerFound = false
2026-10-08 01:42:26 +03:00
var columns: List<Column> = emptyList()
var teachers: List<String> = emptyList()
val days = mutableListOf<Day>()
var currentDate: LocalDate? = null
var currentIntervals: MutableList<TimeSlot>? = null
fun flushDay() {
val date = currentDate ?: return
val intervals = currentIntervals ?: return
if (intervals.isNotEmpty()) days.add(Day(date, intervals.toList()))
}
for (tr in doc.select("tr")) {
val tds = tr.children().filter { it.tagName() == "td" }
if (tds.size < 2) continue
val first = cellText(tds[0])
val second = cellText(tds[1])
if (!headerFound && first == "День" && second == "Интервал") {
2026-10-08 01:42:26 +03:00
columns = tds.mapIndexedNotNull { index, td ->
if (index < 2) return@mapIndexedNotNull null
val name = cellText(td.selectFirst("span") ?: td)
if (name.isEmpty()) null else Column(name, index)
}.distinctBy { it.teacher }
teachers = columns.map { it.teacher }
headerFound = true
continue
}
val dayMatch = DAY_REGEX.matchEntire(first)
val intervalMatch = INTERVAL_REGEX.matchEntire(if (dayMatch != null) second else first)
if (dayMatch != null) {
flushDay()
currentDate = LocalDate.parse(dayMatch.groupValues[2], DATE_FMT)
currentIntervals = mutableListOf()
}
if (intervalMatch != null && currentIntervals != null) {
2026-10-08 01:42:26 +03:00
parseIntervalRow(tds, intervalMatch, dayMatch != null, columns, currentIntervals!!)
}
}
flushDay()
Log.d(TAG, "parse: teachers=$teachers days=${days.size}")
for (day in days) {
Log.d(TAG, " parse ${day.date}: intervals=${day.intervals.size}")
for (slot in day.intervals) {
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
val teachersInSlot = slot.lessons.map { it.teacher }.distinct()
Log.d(
TAG,
" slot ${slot.start}-${slot.end} total=${slot.lessons.size} meltsov=${mel.size} teachers=$teachersInSlot"
)
mel.forEach { l ->
Log.d(TAG, " mel: room=${l.room} group=${l.group} subj=${l.subject} type=${l.type} raw=${l.raw}")
}
}
}
return ParsedTeacherSchedule(teachers, days)
}
private fun parseIntervalRow(
tds: List<Element>,
intervalMatch: MatchResult,
2026-10-08 01:42:26 +03:00
dayRow: Boolean,
columns: List<Column>,
intervals: MutableList<TimeSlot>
) {
val start = LocalTime.parse(intervalMatch.groupValues[1])
val end = LocalTime.parse(intervalMatch.groupValues[2])
val lessons = mutableListOf<Lesson>()
2026-10-08 01:42:26 +03:00
for (column in columns) {
// Ряд с днём содержит обе ведущие ячейки (День, Интервал) и совпадает
// по индексам с шапкой; ряд только с интервалом пропускает ячейку дня
// (rowspan) — его ячейки сдвинуты на одну позицию влево.
val dataIndex = if (dayRow) column.headerIndex else column.headerIndex - 1
val cell = tds.getOrNull(dataIndex) ?: continue
lessons += parseCell(cell, column.teacher)
}
if (lessons.isNotEmpty()) intervals.add(TimeSlot(start, end, lessons))
}
private fun parseCell(cell: Element, teacher: String): List<Lesson> {
val raw = cellText(cell)
if (raw.isEmpty()) return emptyList()
val room = TextPatterns.ROOM_REGEX.find(raw)?.value
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.find(raw)
val type = LessonType.fromRaw(typeMatch?.value?.trim() ?: "")
val segments = raw.split(SPLIT_BY_GROUP).map { it.trim() }.filter { it.isNotEmpty() }
val groupSegments = segments.filter { GROUP_TOKEN.containsMatchIn(it) }
if (groupSegments.isEmpty()) {
return listOf(
Lesson(
raw = raw,
room = room,
subject = subjectOf(raw, room, typeMatch),
type = type,
teacher = teacher
)
)
}
val subject = subjectOf(raw, room, typeMatch)
return groupSegments.flatMap { segment ->
val groups = GROUP_TOKEN.findAll(segment).map { it.value }.distinct().toList()
val subgroup = TextPatterns.SUBGROUP_REGEX.find(segment)?.value
groups.map { group ->
Lesson(
raw = segment,
room = room,
group = group,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
}
}
}
private fun subjectOf(raw: String, room: String?, typeMatch: MatchResult?): String? {
val end = typeMatch?.range?.first ?: GROUP_TOKEN.find(raw)?.range?.first ?: raw.length
val prefix = raw.substring(0, end).trim()
val stripped = room?.let { prefix.removePrefix(it).trim() } ?: prefix
return stripped.ifEmpty { null }
}
private fun cellText(el: Element): String = el.text().replace('\u00A0', ' ').trim()
}