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

168 lines
7.8 KiB
Kotlin
Raw Normal View History

2026-09-02 23:47:00 +03:00
package agzam.vyatsu.data.remote
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season
import java.io.InputStream
2026-09-02 23:47:00 +03:00
/**
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
* @author Agzam
*/
object GroupsParser {
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
private val parenthesized = Regex("""\(.*?\)\s?""")
private const val CHUNK_SIZE = 8192
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
2026-09-02 23:47:00 +03:00
private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
"Факультет лингвистики (ОРУ)" to "ФЛ",
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
"Электротехнический факультет (ОРУ)" to "ЭТФ",
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
)
/**
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
*
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
2026-09-02 23:47:00 +03:00
*/
fun parseStream(input: InputStream): ParsedGroups {
val reader = input.bufferedReader(Charsets.UTF_8)
val buf = CharArray(CHUNK_SIZE)
val pending = StringBuilder(CHUNK_SIZE * 2)
val groups = ArrayList<Group>()
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
var faculty = ""
fun addRanges(text: String) {
for (m in groupPattern.findAll(text)) {
val groupId = m.groupValues[1]
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
val range = DateRange(m.groupValues[3], m.groupValues[4])
ranges.getOrPut(groupId) {
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
}.getOrPut(season) { ArrayList() }.add(range)
}
}
while (true) {
val n = reader.read(buf)
if (n < 0) break
pending.append(buf, 0, n)
var pos = 0
while (pos < pending.length) {
val divAt = pending.indexOf("<div", pos)
val aAt = pending.indexOf("<a", pos)
val tagStart = when {
divAt < 0 -> aAt
aAt < 0 -> divAt
else -> minOf(divAt, aAt)
}
if (tagStart < 0) {
// хвостовой текст без div-тегов — обычный текст страницы
pos = pending.length
break
}
if (tagStart > pos) {
addRanges(pending.substring(pos, tagStart))
2026-09-02 23:47:00 +03:00
}
val tagEnd = findTagEnd(pending, tagStart)
if (tagEnd < 0) break // тег не дочитан — ждём следующий чанк
val tagText = pending.substring(tagStart, tagEnd + 1)
val isDiv = tagText.startsWith("<div")
val isFakName = isDiv && tagText.contains("class=\"fak_name\"")
val isGrpPeriod = isDiv && tagText.contains("class=\"grpPeriod\"")
if (isFakName || isGrpPeriod) {
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
if (closeDiv < 0) break // содержимое не дочитано — ждём следующий чанк
addRanges(tagText)
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val name = content.trim()
if (name.isNotEmpty()) faculty = shortname(name)
} else {
val m = grpIdPattern.find(tagText)
if (m != null) {
groups += Group(
id = m.groupValues[1].dropLast(1),
name = removeParenthesized(content),
faculty = faculty
)
}
}
pos = closeDiv + 6
} else {
addRanges(tagText)
pos = tagEnd + 1
}
if (pos > MAX_SLICE) {
pending.delete(0, pos)
pos = 0
}
}
if (pos > 0 && pos <= pending.length) {
pending.delete(0, pos)
}
if (pending.length > MAX_PENDING) {
// на странице нет настолько длинных конструкций; страховка от аномалий
pending.delete(0, pending.length - MAX_SLICE)
2026-09-02 23:47:00 +03:00
}
}
return ParsedGroups(groups, ranges)
2026-09-02 23:47:00 +03:00
}
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
private fun findTagEnd(s: CharSequence, start: Int): Int {
var i = start
var quote = 0.toChar()
while (i < s.length) {
val c = s[i]
if (quote != 0.toChar()) {
if (c == quote) quote = 0.toChar()
} else {
when (c) {
'"', '\'' -> quote = c
'>' -> return i
}
}
i++
2026-09-02 23:47:00 +03:00
}
return -1
2026-09-02 23:47:00 +03:00
}
private fun shortname(name: String): String {
val stripped = removeParenthesized(name)
val sh = shorthands[name]
return if (sh != null) "$sh ($stripped)" else stripped
}
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
2026-09-02 23:47:00 +03:00
}
data class ParsedGroups(
val groups: List<Group>,
val dateRanges: Map<String, Map<Season, List<DateRange>>>
)