feat(remote): add streaming TeachersParser
This commit is contained in:
@@ -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 groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
||||||
|
|
||||||
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
||||||
private val parenthesized = Regex("""\(.*?\)\s?""")
|
|
||||||
|
|
||||||
private const val CHUNK_SIZE = 8192
|
private const val CHUNK_SIZE = 8192
|
||||||
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
||||||
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
|
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 в память.
|
* разбирает его на лету, не материализуя весь HTML в память.
|
||||||
@@ -80,8 +61,7 @@ object GroupsParser {
|
|||||||
else -> minOf(divAt, aAt)
|
else -> minOf(divAt, aAt)
|
||||||
}
|
}
|
||||||
if (tagStart < 0) {
|
if (tagStart < 0) {
|
||||||
// хвостовой текст без div-тегов — обычный текст страницы
|
pos = keepPartialTagTail(pending, pos)
|
||||||
pos = pending.length
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (tagStart > pos) {
|
if (tagStart > pos) {
|
||||||
@@ -101,13 +81,13 @@ object GroupsParser {
|
|||||||
val content = pending.substring(tagEnd + 1, closeDiv)
|
val content = pending.substring(tagEnd + 1, closeDiv)
|
||||||
if (isFakName) {
|
if (isFakName) {
|
||||||
val name = content.trim()
|
val name = content.trim()
|
||||||
if (name.isNotEmpty()) faculty = shortname(name)
|
if (name.isNotEmpty()) faculty = FacultyNames.shortname(name)
|
||||||
} else {
|
} else {
|
||||||
val m = grpIdPattern.find(tagText)
|
val m = grpIdPattern.find(tagText)
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
groups += Group(
|
groups += Group(
|
||||||
id = m.groupValues[1].dropLast(1),
|
id = m.groupValues[1].dropLast(1),
|
||||||
name = removeParenthesized(content),
|
name = FacultyNames.removeParenthesized(content),
|
||||||
faculty = faculty
|
faculty = faculty
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -153,13 +133,22 @@ object GroupsParser {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shortname(name: String): String {
|
/**
|
||||||
val stripped = removeParenthesized(name)
|
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
|
||||||
val sh = shorthands[name]
|
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
|
||||||
return if (sh != null) "$sh ($stripped)" else stripped
|
*/
|
||||||
|
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(
|
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
@@ -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>
|
||||||
Reference in New Issue
Block a user