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
|
2026-09-11 01:34:05 +03:00
|
|
|
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""")
|
|
|
|
|
|
2026-09-11 01:34:05 +03:00
|
|
|
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
|
|
|
|
|
|
|
|
|
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
|
|
|
/**
|
2026-09-11 01:34:05 +03:00
|
|
|
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
|
|
|
|
* разбирает его на лету, не материализуя весь HTML в память.
|
|
|
|
|
*
|
|
|
|
|
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
|
|
|
|
|
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
|
|
|
|
|
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
|
2026-09-02 23:47:00 +03:00
|
|
|
*/
|
2026-09-11 01:34:05 +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) {
|
2026-09-19 14:47:46 +03:00
|
|
|
pos = keepPartialTagTail(pending, pos)
|
2026-09-11 01:34:05 +03:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if (tagStart > pos) {
|
|
|
|
|
addRanges(pending.substring(pos, tagStart))
|
2026-09-02 23:47:00 +03:00
|
|
|
}
|
2026-09-11 01:34:05 +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()
|
2026-09-19 14:47:46 +03:00
|
|
|
if (name.isNotEmpty()) faculty = FacultyNames.shortname(name)
|
2026-09-11 01:34:05 +03:00
|
|
|
} else {
|
|
|
|
|
val m = grpIdPattern.find(tagText)
|
|
|
|
|
if (m != null) {
|
|
|
|
|
groups += Group(
|
|
|
|
|
id = m.groupValues[1].dropLast(1),
|
2026-09-19 14:47:46 +03:00
|
|
|
name = FacultyNames.removeParenthesized(content),
|
2026-09-11 01:34:05 +03:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
2026-09-11 01:34:05 +03:00
|
|
|
return ParsedGroups(groups, ranges)
|
2026-09-02 23:47:00 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-11 01:34:05 +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
|
|
|
}
|
2026-09-11 01:34:05 +03:00
|
|
|
return -1
|
2026-09-02 23:47:00 +03:00
|
|
|
}
|
|
|
|
|
|
2026-09-19 14:47:46 +03:00
|
|
|
/**
|
|
|
|
|
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
|
|
|
|
|
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
|
|
|
|
|
*/
|
|
|
|
|
private fun keepPartialTagTail(pending: CharSequence, from: Int): Int {
|
|
|
|
|
var i = from
|
|
|
|
|
while (i < pending.length) {
|
|
|
|
|
val c = pending[i]
|
|
|
|
|
if (c == '<') {
|
|
|
|
|
val tail = pending.substring(i, pending.length)
|
|
|
|
|
if (tail == "<" || tail == "<d" || tail == "<di" || tail == "<a") return i
|
|
|
|
|
}
|
|
|
|
|
i++
|
|
|
|
|
}
|
|
|
|
|
return pending.length
|
2026-09-02 23:47:00 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-11 01:34:05 +03:00
|
|
|
|
|
|
|
|
data class ParsedGroups(
|
|
|
|
|
val groups: List<Group>,
|
|
|
|
|
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
|
|
|
|
)
|