forked from agzam/vyatsu-schedule
feat(remote): add jsoup TeacherScheduleParser
This commit is contained in:
@@ -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<String>, val days: List<Day>)
|
||||
|
||||
fun parse(html: String): ParsedTeacherSchedule {
|
||||
val doc = Jsoup.parse(html)
|
||||
var headerFound = false
|
||||
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 == "Интервал") {
|
||||
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<Element>,
|
||||
intervalMatch: MatchResult,
|
||||
startCol: Int,
|
||||
teachers: List<String>,
|
||||
intervals: MutableList<TimeSlot>
|
||||
) {
|
||||
val start = LocalTime.parse(intervalMatch.groupValues[1])
|
||||
val end = LocalTime.parse(intervalMatch.groupValues[2])
|
||||
val lessons = mutableListOf<Lesson>()
|
||||
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<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()
|
||||
}
|
||||
@@ -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<Day> {
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<TABLE>
|
||||
<TR>
|
||||
<TD><SPAN>День</SPAN></TD>
|
||||
<TD><SPAN>Интервал</SPAN></TD>
|
||||
<TD><SPAN>Лелекова Е.В.</SPAN></TD>
|
||||
<TD><SPAN>Иванов И.И.</SPAN></TD>
|
||||
<TD><SPAN>Петрова А.С.</SPAN></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD ROWSPAN=7><SPAN>понедельник 14.09.26</SPAN></TD>
|
||||
<TD><SPAN>08:20-09:50</SPAN></TD>
|
||||
<TD><SPAN>4-302 Биоразнообразие Лекция Бб-1101-04-00 Бб-1101-04-00, 01 подгруппа</SPAN></TD>
|
||||
<TD><SPAN>4-308 Методика познания (учитель биологии) Практическое занятие ПОДб-4701-08-00</SPAN></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>10:00-11:30</SPAN></TD>
|
||||
<TD><SPAN>Бб-1101-04-00 Бб-1101-04-00, 01 подгруппа</SPAN></TD>
|
||||
<TD><SPAN>Методическое совещание</SPAN></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>12:00-13:30</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>14:00-15:30</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>15:40-17:10</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>17:15-18:45</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>18:55-20:25</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD ROWSPAN=7><SPAN>вторник 15.09.26</SPAN></TD>
|
||||
<TD><SPAN>08:20-09:50</SPAN></TD>
|
||||
<TD><SPAN>4-507 Биоразнообразие Лабораторная работа Бб-1102-04-00 Бб-1102-04-00, 01 подгруппа</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD><SPAN>1-116 Статистическая обработка экспериментальных данных Практическое занятие БТб-2101-03-00 БТб-2101-03-00, 02 подгруппа</SPAN></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>10:00-11:30</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD><SPAN>4-402a Фиторесурсы Лекция Бм-2102-03-00</SPAN></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>12:00-13:30</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>14:00-15:30</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>15:40-17:10</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>17:15-18:45</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
<TR>
|
||||
<TD><SPAN>18:55-20:25</SPAN></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
<TD></TD>
|
||||
</TR>
|
||||
</TABLE>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user