2026-09-19 14:47:55 +03:00
|
|
|
|
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
|
2026-09-20 15:13:27 +03:00
|
|
|
|
import android.util.Log
|
2026-09-19 14:47:55 +03:00
|
|
|
|
import org.jsoup.Jsoup
|
|
|
|
|
|
import org.jsoup.nodes.Element
|
|
|
|
|
|
import java.time.LocalDate
|
|
|
|
|
|
import java.time.LocalTime
|
|
|
|
|
|
import java.time.format.DateTimeFormatter
|
|
|
|
|
|
|
|
|
|
|
|
object TeacherScheduleParser {
|
|
|
|
|
|
|
2026-09-20 15:13:27 +03:00
|
|
|
|
private const val TAG = "TeacherParserDebug"
|
2026-09-19 14:47:55 +03:00
|
|
|
|
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})$""")
|
2026-09-19 20:44:22 +03:00
|
|
|
|
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}]))""")
|
2026-09-19 14:47:55 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-09-19 14:47:55 +03:00
|
|
|
|
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()
|
2026-09-19 14:47:55 +03:00
|
|
|
|
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 }
|
2026-09-19 14:47:55 +03:00
|
|
|
|
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!!)
|
2026-09-19 14:47:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
flushDay()
|
2026-09-20 15:13:27 +03:00
|
|
|
|
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}")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-19 14:47:55 +03:00
|
|
|
|
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>,
|
2026-09-19 14:47:55 +03:00
|
|
|
|
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)
|
2026-09-19 14:47:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|