From 32c2b60afa0d86eb82326517f39614b010c292ba Mon Sep 17 00:00:00 2001 From: Agzamus Date: Sat, 19 Sep 2026 14:47:55 +0300 Subject: [PATCH] feat(remote): add jsoup TeacherScheduleParser --- .../data/remote/TeacherScheduleParser.kt | 128 ++++++++++++++++++ .../data/remote/pdf/PdfScheduleParser.kt | 10 +- .../vyatsu/data/remote/pdf/TextPatterns.kt | 12 ++ .../agzam/vyatsu/TeacherScheduleParserTest.kt | 86 ++++++++++++ app/src/test/resources/prepod.html | 102 ++++++++++++++ 5 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/agzam/vyatsu/data/remote/TeacherScheduleParser.kt create mode 100644 app/src/main/java/agzam/vyatsu/data/remote/pdf/TextPatterns.kt create mode 100644 app/src/test/java/agzam/vyatsu/TeacherScheduleParserTest.kt create mode 100644 app/src/test/resources/prepod.html diff --git a/app/src/main/java/agzam/vyatsu/data/remote/TeacherScheduleParser.kt b/app/src/main/java/agzam/vyatsu/data/remote/TeacherScheduleParser.kt new file mode 100644 index 0000000..dead194 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/data/remote/TeacherScheduleParser.kt @@ -0,0 +1,128 @@ +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 org.jsoup.Jsoup +import org.jsoup.nodes.Element +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +object TeacherScheduleParser { + + 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("""(?U)\b[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}\b""") + private val SPLIT_BY_GROUP = Regex("""(?U)(?=\b[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}\b)""") + private val DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yy") + + data class ParsedTeacherSchedule(val teachers: List, val days: List) + + fun parse(html: String): ParsedTeacherSchedule { + val doc = Jsoup.parse(html) + var headerFound = false + var teachers: List = emptyList() + val days = mutableListOf() + var currentDate: LocalDate? = null + var currentIntervals: MutableList? = 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 == "Интервал") { + teachers = tds.drop(2) + .map { cellText(it.selectFirst("span") ?: it) } + .filter { it.isNotEmpty() } + .distinct() + 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) { + parseIntervalRow(tds, intervalMatch, if (dayMatch != null) 2 else 1, teachers, currentIntervals!!) + } + } + flushDay() + return ParsedTeacherSchedule(teachers, days) + } + + private fun parseIntervalRow( + tds: List, + intervalMatch: MatchResult, + startCol: Int, + teachers: List, + intervals: MutableList + ) { + val start = LocalTime.parse(intervalMatch.groupValues[1]) + val end = LocalTime.parse(intervalMatch.groupValues[2]) + val lessons = mutableListOf() + for (i in teachers.indices) { + val cell = tds.getOrNull(startCol + i) ?: continue + lessons += parseCell(cell, teachers[i]) + } + if (lessons.isNotEmpty()) intervals.add(TimeSlot(start, end, lessons)) + } + + private fun parseCell(cell: Element, teacher: String): List { + 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() +} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/data/remote/pdf/PdfScheduleParser.kt b/app/src/main/java/agzam/vyatsu/data/remote/pdf/PdfScheduleParser.kt index b1fc563..00289cc 100644 --- a/app/src/main/java/agzam/vyatsu/data/remote/pdf/PdfScheduleParser.kt +++ b/app/src/main/java/agzam/vyatsu/data/remote/pdf/PdfScheduleParser.kt @@ -26,10 +26,6 @@ object PdfScheduleParser { private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() } private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() } - private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""") - private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } ) - private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE) - fun parse(pdfBytes: ByteArray): List { Log.d(TAG, "Parsing ${pdfBytes.size} bytes") val doc = try { @@ -145,7 +141,7 @@ object PdfScheduleParser { // Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы var found = 0 var currentStart = 0 - for (roomMatch in ROOM_REGEX.findAll(content)) { + for (roomMatch in TextPatterns.ROOM_REGEX.findAll(content)) { found++ val raw = content.substring(currentStart, roomMatch.range.last + 1) @@ -155,7 +151,7 @@ object PdfScheduleParser { val room = roomMatch.value - val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList() + val typeMatch = TextPatterns.LESSON_TYPE_REGEX.findAll(raw).toList() if(typeMatch.isEmpty()) { lessons.add(Lesson( raw=raw, @@ -170,7 +166,7 @@ object PdfScheduleParser { Log.d(TAG, "$subject $group") if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) { subject = subject.substring(group.length + 1).trim() - val subgroupMatch = SUBGROUP_REGEX.find(subject) + val subgroupMatch = TextPatterns.SUBGROUP_REGEX.find(subject) if(subgroupMatch != null) { subgroup = subgroupMatch.value.trim() subject = subject.removeRange(subgroupMatch.range).trim() diff --git a/app/src/main/java/agzam/vyatsu/data/remote/pdf/TextPatterns.kt b/app/src/main/java/agzam/vyatsu/data/remote/pdf/TextPatterns.kt new file mode 100644 index 0000000..8c15b60 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/data/remote/pdf/TextPatterns.kt @@ -0,0 +1,12 @@ +package agzam.vyatsu.data.remote.pdf + +import agzam.vyatsu.domain.model.LessonType + +object TextPatterns { + + val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""") + + val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" }) + + val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE) +} \ No newline at end of file diff --git a/app/src/test/java/agzam/vyatsu/TeacherScheduleParserTest.kt b/app/src/test/java/agzam/vyatsu/TeacherScheduleParserTest.kt new file mode 100644 index 0000000..6ecf3c3 --- /dev/null +++ b/app/src/test/java/agzam/vyatsu/TeacherScheduleParserTest.kt @@ -0,0 +1,86 @@ +package agzam.vyatsu + +import agzam.vyatsu.data.remote.TeacherScheduleParser +import agzam.vyatsu.domain.model.LessonType +import java.time.LocalDate +import java.time.LocalTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TeacherScheduleParserTest { + + private fun fixture(): String = + checkNotNull(javaClass.getResourceAsStream("/prepod.html")) + .readBytes().toString(Charsets.UTF_8) + + @Test + fun teachersHeaderParsedInOrderAndDeduped() { + val parsed = TeacherScheduleParser.parse(fixture()) + assertEquals(listOf("Лелекова Е.В.", "Иванов И.И.", "Петрова А.С."), parsed.teachers) + } + + @Test + fun daysWithCorrectDates() { + val parsed = TeacherScheduleParser.parse(fixture()) + assertEquals(2, parsed.days.size) + assertEquals(LocalDate.of(2026, 9, 14), parsed.days[0].date) + assertEquals(LocalDate.of(2026, 9, 15), parsed.days[1].date) + } + + @Test + fun intervalTimesParsed() { + val parsed = TeacherScheduleParser.parse(fixture()) + val day0 = parsed.days[0] + assertEquals(2, day0.intervals.size) + assertEquals(LocalTime.of(8, 20), day0.intervals[0].start) + assertEquals(LocalTime.of(9, 50), day0.intervals[0].end) + assertEquals(LocalTime.of(10, 0), day0.intervals[1].start) + assertEquals(LocalTime.of(11, 30), day0.intervals[1].end) + } + + @Test + fun multiGroupCellSplitsIntoPerGroupLessons() { + val parsed = TeacherScheduleParser.parse(fixture()) + val cell = parsed.days[0].intervals[0].lessons.filter { it.teacher == "Лелекова Е.В." } + assertEquals(2, cell.size) + val lesson0 = cell[0] + val lesson1 = cell[1] + assertEquals("Бб-1101-04-00", lesson0.group) + assertNull(lesson0.subgroup) + assertEquals("01 подгруппа", lesson1.subgroup) + assertEquals("4-302", lesson0.room) + assertEquals("Биоразнообразие", lesson0.subject) + assertEquals(LessonType.LECTURE, lesson0.type) + assertEquals("Лелекова Е.В.", lesson0.teacher) + } + + @Test + fun emptyCellContributesNoLesson() { + val parsed = TeacherScheduleParser.parse(fixture()) + val threeSlots = (0..6).map { slot -> + parsed.days[0].intervals.firstOrNull { it.start.hour == 8 && it.start.minute == 20 } ?: return@map true + } + val petrovLessons = parsed.days[0].intervals[0].lessons.filter { it.teacher == "Петрова А.С." } + assertEquals(0, petrovLessons.size) + assertTrue(threeSlots.isEmpty() || threeSlots.size == 7) + } + + @Test + fun noRoomCellKeepsRawAndNullRoom() { + val parsed = TeacherScheduleParser.parse(fixture()) + val cell = parsed.days[0].intervals[1].lessons.filter { it.teacher == "Лелекова Е.В." } + assertEquals(2, cell.size) + assertNull(cell[0].room) + assertTrue(cell[0].raw.contains("Бб-1101-04-00")) + assertNull(cell[0].subject) + } + + @Test + fun exactTotalLessonCount() { + val parsed = TeacherScheduleParser.parse(fixture()) + val total = parsed.days.sumOf { day -> day.intervals.sumOf { it.lessons.size } } + assertEquals(11, total) + } +} \ No newline at end of file diff --git a/app/src/test/resources/prepod.html b/app/src/test/resources/prepod.html new file mode 100644 index 0000000..bd28a79 --- /dev/null +++ b/app/src/test/resources/prepod.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ДеньИнтервалЛелекова Е.В.Иванов И.И.Петрова А.С.
понедельник 14.09.2608:20-09:504-302 Биоразнообразие Лекция Бб-1101-04-00 Бб-1101-04-00, 01 подгруппа4-308 Методика познания (учитель биологии) Практическое занятие ПОДб-4701-08-00
10:00-11:30Бб-1101-04-00 Бб-1101-04-00, 01 подгруппаМетодическое совещание
12:00-13:30
14:00-15:30
15:40-17:10
17:15-18:45
18:55-20:25
вторник 15.09.2608:20-09:504-507 Биоразнообразие Лабораторная работа Бб-1102-04-00 Бб-1102-04-00, 01 подгруппа1-116 Статистическая обработка экспериментальных данных Практическое занятие БТб-2101-03-00 БТб-2101-03-00, 02 подгруппа
10:00-11:304-402a Фиторесурсы Лекция Бм-2102-03-00
12:00-13:30
14:00-15:30
15:40-17:10
17:15-18:45
18:55-20:25
+ + \ No newline at end of file