feat(remote): add jsoup TeacherScheduleParser

This commit is contained in:
2026-09-19 14:47:55 +03:00
parent 458e4c4e69
commit 32c2b60afa
5 changed files with 331 additions and 7 deletions
@@ -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)
}