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 /** * Основано на 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 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 — жёсткий потолок буфера /** * Стриминговый парсер страницы групп: читает тело ответа по чанкам и * разбирает его на лету, не материализуя весь HTML в память. * * Страница весит ~2.6MB, а на устройствах с маленькой кучей даже * одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM). * За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок. */ 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() val ranges = HashMap>>() 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(" aAt aAt < 0 -> divAt else -> minOf(divAt, aAt) } if (tagStart < 0) { pos = keepPartialTagTail(pending, pos) break } if (tagStart > pos) { addRanges(pending.substring(pos, tagStart)) } val tagEnd = findTagEnd(pending, tagStart) if (tagEnd < 0) break // тег не дочитан — ждём следующий чанк val tagText = pending.substring(tagStart, tagEnd + 1) val isDiv = tagText.startsWith("", 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 = FacultyNames.shortname(name) } else { val m = grpIdPattern.find(tagText) if (m != null) { groups += Group( id = m.groupValues[1].dropLast(1), name = FacultyNames.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) } } return ParsedGroups(groups, ranges) } /** Ищет конец 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++ } return -1 } /** * Хвост буфера может содержать неполное начало тега (, val dateRanges: Map>> )