feat(remote): add streaming TeachersParser

This commit is contained in:
2026-09-19 14:47:46 +03:00
parent 1a4bff73d0
commit 458e4c4e69
7 changed files with 18086 additions and 29 deletions
@@ -0,0 +1,36 @@
package agzam.vyatsu.data.remote
/**
* Справочник сокращённых названий факультетов ВятГУ.
* Вынесен из GroupsParser, так как TeachersParser использует те же сокращения.
*/
object FacultyNames {
private val parenthesized = Regex("""\(.*?\)\s?""")
private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
"Факультет лингвистики (ОРУ)" to "ФЛ",
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
"Электротехнический факультет (ОРУ)" to "ЭТФ",
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
)
fun shortname(name: String): String {
val stripped = removeParenthesized(name)
val sh = shorthands[name]
return if (sh != null) "$sh ($stripped)" else stripped
}
fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
}
@@ -14,30 +14,11 @@ 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 — жёсткий потолок буфера
private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
"Факультет лингвистики (ОРУ)" to "ФЛ",
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
"Электротехнический факультет (ОРУ)" to "ЭТФ",
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
)
/**
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
@@ -80,8 +61,7 @@ object GroupsParser {
else -> minOf(divAt, aAt)
}
if (tagStart < 0) {
// хвостовой текст без div-тегов — обычный текст страницы
pos = pending.length
pos = keepPartialTagTail(pending, pos)
break
}
if (tagStart > pos) {
@@ -101,13 +81,13 @@ object GroupsParser {
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val name = content.trim()
if (name.isNotEmpty()) faculty = shortname(name)
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 = removeParenthesized(content),
name = FacultyNames.removeParenthesized(content),
faculty = faculty
)
}
@@ -153,13 +133,22 @@ object GroupsParser {
return -1
}
private fun shortname(name: String): String {
val stripped = removeParenthesized(name)
val sh = shorthands[name]
return if (sh != null) "$sh ($stripped)" else stripped
/**
* Хвост буфера может содержать неполное начало тега (<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
}
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
}
data class ParsedGroups(
@@ -0,0 +1,148 @@
package agzam.vyatsu.data.remote
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Department
import agzam.vyatsu.domain.model.ParsedDepartments
import agzam.vyatsu.domain.model.Season
import java.io.InputStream
/**
* Стриминговый парсер страницы преподавателей: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
* Страница teacher.html весит ~2.4MB, поэтому весь документ грузить нельзя.
*/
object TeachersParser {
private val fakNameTag = Regex("""<div[^>]*class="fak_name"""")
private val fakIdInTag = Regex("""data-fak_id="(\d+)"""")
private val kafPeriodTag = Regex("""<div[^>]*class="kafPeriod"""")
private val kafIdInTag = Regex("""data-kaf_period_id="(\d+)"""")
private val prepodLink = Regex("""href="/reports/schedule/prepod/(\d+)_([12])_(\d{8})_(\d{8})\.html"""")
private val tagStrip = Regex("""<[^>]*>""")
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 — жёсткий потолок буфера
fun parseStream(input: InputStream): ParsedDepartments {
val reader = input.bufferedReader(Charsets.UTF_8)
val buf = CharArray(CHUNK_SIZE)
val pending = StringBuilder(CHUNK_SIZE * 2)
val departments = ArrayList<Department>()
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
var faculty = ""
fun addRanges(text: String) {
for (m in prepodLink.findAll(text)) {
val departmentId = 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(departmentId) {
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) {
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("<div")
val isFakName = isDiv && fakNameTag.containsMatchIn(tagText)
val isKafPeriod = isDiv && kafPeriodTag.containsMatchIn(tagText)
if (isFakName || isKafPeriod) {
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
if (closeDiv < 0) break
addRanges(tagText)
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val stripped = tagStrip.replace(content, "").trim()
if (stripped.isNotEmpty()) faculty = FacultyNames.shortname(stripped)
} else {
val m = kafIdInTag.find(tagText)
if (m != null) {
departments += Department(
departmentId = m.groupValues[1].dropLast(1),
name = tagStrip.replace(content, "").trim(),
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 ParsedDepartments(departments, 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
}
/**
* Хвост буфера может содержать неполное начало тега (<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
}
}
@@ -0,0 +1,19 @@
package agzam.vyatsu
import agzam.vyatsu.data.remote.GroupsParser
import org.junit.Assert.assertTrue
import org.junit.Test
class GroupsPipelineMemoryTest {
@Test
fun streamingUnderSmallHeap() {
val fixture = checkNotNull(javaClass.getResourceAsStream("/groups.html")) {
"Fixture groups.html not found in src/test/resources"
}
val result = fixture.use(GroupsParser::parseStream)
assertTrue(result.groups.size > 400)
assertTrue(result.groups.any { it.id == "26981" && it.name.contains("ПОДб-1701-08-00") })
assertTrue(result.dateRanges.size > 400)
}
}
@@ -0,0 +1,76 @@
package agzam.vyatsu
import agzam.vyatsu.data.remote.TeachersParser
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Season
import java.io.InputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TeachersPipelineMemoryTest {
private fun fixture(): InputStream =
checkNotNull(javaClass.getResourceAsStream("/teacher.html")) {
"Fixture teacher.html not found in src/test/resources"
}
@Test
fun parsesDepartmentsAndFaculties() {
val result = fixture().use(TeachersParser::parseStream)
assertTrue("expected >=4 departments", result.departments.size >= 4)
val ids = result.departments.map { it.departmentId }.toSet()
assertEquals(setOf("11111", "22222", "33333", "44444"), ids)
val names = result.departments.map { it.name }
assertTrue(
"nested tags must be stripped from kafPeriod names",
names.contains("Кафедра технологий и конструирования машин и аппаратов")
)
val avt = result.departments.first { it.departmentId == "11111" }
assertTrue("faculty must be mapped via shortname", avt.faculty.contains("ФАВТ"))
}
@Test
fun chunkBoundarySafe() {
val full = fixture().use(TeachersParser::parseStream)
val limited = limitReads(fixture(), 64).use(TeachersParser::parseStream)
assertEquals(full, limited)
}
@Test
fun seasonAndRanges() {
val result = fixture().use(TeachersParser::parseStream)
val hasAutumn = result.dateRanges.values.any { it[Season.AUTUMN]?.isNotEmpty() == true }
val hasSpring = result.dateRanges.values.any { it[Season.SPRING]?.isNotEmpty() == true }
assertTrue("AUTUMN season must be present", hasAutumn)
assertTrue("SPRING season must be present", hasSpring)
val autumn = result.dateRanges["11111"]?.get(Season.AUTUMN).orEmpty()
assertTrue(
"DateRange must be parsed from URL groups",
autumn.contains(DateRange("01092026", "13092026"))
)
assertTrue(
"second AUTUMN range must be present",
autumn.contains(DateRange("14092026", "27092026"))
)
val spring = result.dateRanges["44444"]?.get(Season.SPRING).orEmpty()
assertTrue(
"SPRING DateRange must be parsed from URL groups",
spring.contains(DateRange("02022026", "27022026"))
)
}
private fun limitReads(input: InputStream, maxBytes: Int): InputStream =
object : InputStream() {
override fun read(): Int = input.read()
override fun read(b: ByteArray, off: Int, len: Int): Int =
input.read(b, off, minOf(len, maxBytes))
}
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="content-language" content="ru">
<title>Расписание занятий преподавателей - Официальный сайт ВятГУ</title>
</head>
<body>
<div id="headerEduPrograms">
<div class="block-content">
<h2>Планируемое расписание занятий преподавателей</h2>
<div class="fak_name" data-fak_id="fak_avt_41"><span>Факультет автоматики</span> и вычислительной техники (ОРУ)</div>
<div class="block-content" id="fak_avt_41" style="display: none;">
<table>
<tbody>
<tr>
<td>
<div
class="kafPeriod"
data-kaf_period_id="111111"
>
Кафедра алгоритмов и программ </div>
<div
class="listPeriod"
id="listPeriod_111111"
style="display: none;"
>
<a target="_blank" href="/reports/schedule/prepod/11111_1_01092026_13092026.html">с 01 09 2026 по 13 09 2026</a>
<br/>
<a target="_blank" href="/reports/schedule/prepod/11111_1_14092026_27092026.html">с 14 09 2026 по 27 09 2026</a>
<br/>
</div>
</td>
<td>
<div
class="kafPeriod"
data-kaf_period_id="222221"
>
Кафедра <b>технологий и конструирования</b> машин и аппаратов </div>
<div
class="listPeriod"
id="listPeriod_222221"
style="display: none;"
>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="fak_name" data-fak_id="fak_phil_21"><b>Философский</b> факультет</div>
<div class="block-content" id="fak_phil_21" style="display: none;">
<table>
<tbody>
<tr>
<td>
<div class="kafPeriod" data-kaf_period_id="333331">
Кафедра философии </div>
<div
class="listPeriod"
id="listPeriod_333331"
style="display: none;"
>
<a target="_blank" href="/reports/schedule/prepod/33333_1_01092026_13092026.html">с 01 09 2026 по 13 09 2026</a>
<br/>
<a target="_blank" href="/reports/schedule/prepod/33333_1_14092026_27092026.html">с 14 09 2026 по 27 09 2026</a>
<br/>
</div>
</td>
<td>
<div
class="kafPeriod"
data-kaf_period_id="444441"
>
Кафедра логики и методологии науки </div>
<div
class="listPeriod"
id="listPeriod_444441"
style="display: none;"
>
<a target="_blank" href="/reports/schedule/prepod/44444_2_02022026_27022026.html">с 02 02 2027 по 27 02 2026</a>
<br/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>