个人适配WakeUP学校教务课表记录

开头情景

第一次使用 WakeUp 课程表时,我想使用教务系统导入课程表,但发现自己的学校没有适配教务系统。尝试使用通用教务系统导入后,发现部分课程没有导入,信息也比较混乱,所以我决定自己动手进行适配,但是我并不会写Kotlin代码,当时时间比较紧,就使用AI完成了这个适配,而且效果还不错。

第一步,我们前往 WakeUp 官方提供的适配器仓库 YZune/CourseAdapter,了解适配的基本原理和结构。

适配器代码分析

适配器的核心是解析教务系统网页的 HTML 源码,提取出课程信息,并将其转换为 WakeUp App 所要求的标准格式。下面我将以我为武汉船舶职业技术学院(基于学校的金智教务系统)编写的适配器为例。

1. 核心解析器:WISTParser.kt

这个文件是整个适配器的灵魂,它负责从原始的 HTML 文本中提取、清洗和组织课程数据。

主要特性

在开始分析代码前,先看一下这个解析器实现了哪些功能:

  • 自动清洗周次:支持 “单周”、”双周”、”X-X周” 等复杂格式。
  • 智能合并课程:将同一门课程在不同时间点的记录合并。
  • 合并连续节次:自动将 3-4 节这样的连续课程合并为一个条目。
  • 结构化数据输出:生成标准的课程列表,包含所有必要信息。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
package main.java.parser

import bean.Course
import Common
import main.java.bean.TimeDetail
import main.java.bean.TimeTable
import org.jsoup.Jsoup
import parser.Parser

/*
* WISTParser 教务课表解析器
* 适配:武汉船舶职业技术学院 金智教务系统
* 日期:2025-05-22
* 作者:Qing90bing
* 注意:2025-05-22为止,该学校教务系统为金智教育系统
* 因为是测试自己学院的,不知道其他学院情况,有能力的话可以在issue中留言,把课表文件发给我
* 查询流程:
* 1.进入教务系统登录地址(统一身份验证):http://authserver.wspc.edu.cn/authserver/login?service=http%3A%2F%2Fehall.wspc.edu.cn%2Flogin%3Fservice%3Dhttp%3A%2F%2Fehall.wspc.edu.cn%2Fnew%2Findex.html
* 2.然后学生课表查询网址:http://jw.wspc.edu.cn/jwapp/sys/emaphome/portal/index.do
* 3.进入课表会需要一段时间,请耐心等待,直到页面加载完成,然后最好点击打印,再获取课表,目前只是过这个步骤。
*/

class WISTParser(private val source: String) : Parser() {

//生成课程列表的核心逻辑从 HTML 中提取课程信息,转换为标准 Course 列表,合并课表时间与周次信息
override fun generateCourseList(): List<Course> {
val courseList = ArrayList<Course>() // 原始课程列表
val doc = Jsoup.parse(source) // 解析输入的HTML内容
val classNameSet = HashSet<String>() // 班级名称集合(自动去重)

// 获取每个单元格(td)作为一天中的课表格子
val itemCells = doc.select("td[data-role=item]")
for (td in itemCells) {
// 解析星期几(1-7对应周一到周日)
val day = td.attr("data-week").toIntOrNull() ?: continue

// 解析节次范围(数据属性或文本内容)
val beginNodeAttr = td.attr("data-begin-unit").toIntOrNull()
val endNodeAttr = td.attr("data-end-unit").toIntOrNull()

// 提取单元格内的所有课程块
val courseDivs = td.select("div.mtt_arrange_item")
for (block in courseDivs) {
// 解析课程基础信息
val name = block.selectFirst(".mtt_item_kcmc")?.ownText()?.trim() ?: continue
val teacher = block.selectFirst(".mtt_item_jxbmc")?.text()?.trim().orEmpty()
val roomInfoRaw = block.selectFirst(".mtt_item_room")?.text()?.trim().orEmpty()

// 提取班级名(可用于课表命名)
block.selectFirst(".mtt_item_bjmc")?.text()?.trim()?.takeIf { it.isNotEmpty() }?.let {
classNameSet += it
}

// 解析周次信息
val rawParts = roomInfoRaw.split(Regex("[,,]")).map(String::trim)
val weekParts = rawParts.filter { it.contains("周") }
if (weekParts.isEmpty()) continue

val fullWeekStr = weekParts.joinToString(",")
val isOdd = fullWeekStr.contains("单")
val isEven = fullWeekStr.contains("双")

// 仅保留数字部分,去除“周”等非数字干扰
val weekStrCleaned = weekParts.joinToString(",") {
it.replace("周", "")
}.replace(" ", "")

// 提取节次范围(优先使用 data- 属性)
val (beginNode, endNode) = extractNodeRange(beginNodeAttr, endNodeAttr, rawParts)
if (beginNode <= 0 || endNode <= 0) continue
val step = endNode - beginNode + 1
val room = extractRoom(rawParts)

// 按每周生成一个 Course 对象(适配课表结构)
for (week in parseWeeks(weekStrCleaned, isOdd, isEven)) {
courseList += Course(
name = name,
teacher = teacher,
room = room,
day = day,
startNode = beginNode,
endNode = endNode,
step = step,
startWeek = week,
endWeek = week,
type = 0,
note = ""
)
}
}
}

// 合并周次相同、节次相同的课程
val merged = ArrayList<Course>()
Common.mergeWeekCourse(courseList, merged)

// 生成课程时间表(用于后续显示/提醒)
Common.generateTimeTable(merged, generateTimeTable())

// 合并相邻节次(上下节连排)
val optimized = mergeAdjacentNodes(merged)
this.classNames = classNameSet.toList().sorted()
return optimized
}

private var classNames: List<String> = emptyList()

//返回学校课表名称(包含班级信息)
override fun getTableName(): String {
return if (classNames.isNotEmpty()) {
"武船" + classNames.joinToString(",") + "课表"
} else {
"武船课表"
}
}

//获取每日最大节次(固定为12节)
override fun getNodes(): Int = 12

//学校最大周数
override fun getMaxWeek(): Int = 20

//定义标准时间表(武汉船舶职业技术学院专用)
override fun generateTimeTable(): TimeTable = TimeTable(
name = "武汉船舶职业技术学院",
timeList = listOf(
TimeDetail(1, "08:10", "08:55"),
TimeDetail(2, "09:05", "09:50"),
TimeDetail(3, "10:10", "10:55"),
TimeDetail(4, "11:05", "11:50"),
TimeDetail(5, "12:30", "13:10"),
TimeDetail(6, "13:20", "14:00"),
TimeDetail(7, "14:00", "14:45"),
TimeDetail(8, "14:55", "15:40"),
TimeDetail(9, "16:00", "16:45"),
TimeDetail(10, "16:55", "17:40"),
TimeDetail(11, "19:00", "19:45"),
TimeDetail(12, "19:55", "20:40")
)
)
//解析周次字段(如 1-3,5-7(单),10-14(双))
private fun parseWeeks(rawText: String, ignored1: Boolean = false, ignored2: Boolean = false): List<Int> {
val weekList = mutableSetOf<Int>()
val parts = rawText.split(",")

for (part in parts) {
val isOdd = part.contains("单")
val isEven = part.contains("双")

// 清洗周次数字部分(如 12-14(双) -> 12-14)
val clean = part.replace(Regex("[^0-9\\-]"), "")
val weekRange = if ("-" in clean) {
val (start, end) = clean.split("-").mapNotNull { it.toIntOrNull() }
(start..end).toList()
} else {
listOfNotNull(clean.toIntOrNull())
}

val filtered = when {
isOdd -> weekRange.filter { it % 2 == 1 }
isEven -> weekRange.filter { it % 2 == 0 }
else -> weekRange
}

weekList += filtered
}

return weekList.toList().sorted()
}

//判断某一周是否是有效的(单/双周筛选)
private fun isValidWeek(week: Int, isOdd: Boolean, isEven: Boolean): Boolean {
return if (isOdd) week % 2 == 1 else if (isEven) week % 2 == 0 else true
}

//解析节次字符串(支持“中1”、“中2”等中午节次)
private fun parseNode(str: String): Int = when {
str.contains("中1") -> 5 // 中午第1节对应第5节
str.contains("中2") -> 6 // 中午第2节对应第6节
else -> str.filter(Char::isDigit).toIntOrNull() ?: -1
}

//提取开始和结束节次
private fun extractNodeRange(beginAttr: Int?, endAttr: Int?, parts: List<String>): Pair<Int, Int> {
return if (beginAttr != null && endAttr != null) {
Pair(beginAttr, endAttr)
} else {
val nodePart = parts.firstOrNull { nodePattern.matches(it) } ?: return Pair(-1, -1)
val nodes = nodePart.split("-")
Pair(parseNode(nodes.first()), parseNode(nodes.last()))
}
}

//提取教室信息
private fun extractRoom(parts: List<String>): String {
val roomRegex = Regex("实验室|教室|机房")
return parts.firstOrNull {
!it.contains("周") && !it.matches(Regex("^(中?[1-9]\\d?)(-(中?[1-9]\\d?))?$")) && roomRegex.containsMatchIn(it)
} ?: parts.firstOrNull {
!it.contains("周") && !it.matches(Regex("^(中?[1-9]\\d?)(-(中?[1-9]\\d?))?$"))
}.orEmpty()
}

//合并相邻节次的课程(如第1-2节与3-4节相邻且内容一致)
private fun mergeAdjacentNodes(courses: List<Course>): List<Course> {
val sorted = courses.sortedWith(
compareBy<Course> { it.name }
.thenBy { it.teacher }
.thenBy { it.room }
.thenBy { it.day }
.thenBy { it.startWeek }
.thenBy { it.endWeek }
.thenBy { it.startNode }
)
val result = ArrayList<Course>()
var current = sorted[0]

for (i in 1 until sorted.size) {
val next = sorted[i]
if (
current.name == next.name &&
current.teacher == next.teacher &&
current.room == next.room &&
current.day == next.day &&
current.startWeek == next.startWeek &&
current.endWeek == next.endWeek &&
current.type == next.type &&
current.endNode + 1 == next.startNode
) {
current = current.copy(
endNode = next.endNode,
step = next.endNode - current.startNode + 1
)
} else {
result += current
current = next
}
}
result += current
return result
}

//从完整 HTML 中提取课表表格部分
companion object {
private val weekPattern = Regex("[^0-9\\-,(单双)]")

private val nodePattern = Regex("^(中?[1-9]\\d?)(-(中?[1-9]\\d?))?$")

fun extractTableHtml(fullHtml: String): String? {
return Jsoup.parse(fullHtml).selectFirst("table.wut_table")?.outerHtml()
}
}
}

关键逻辑拆解

  1. generateCourseList():这是最核心的方法。
    • 它使用 Jsoup.parse(source) 将 HTML 字符串转换成一个可操作的文档对象。
    • 通过 doc.select("td[data-role=item]") 定位到所有包含课程信息的单元格。
    • 遍历每个单元格,提取 data-week (星期几)、data-begin-unit (开始节次) 等关键属性。
    • 在单元格内部,通过 select("div.mtt_arrange_item") 找到具体的课程信息块。
    • 从课程块中解析出课程名 (.mtt_item_kcmc)、教师 (.mtt_item_jxbmc) 和包含周次、教室的混合信息 (.mtt_item_room)。
    • 最关键的一步是解析周次。代码首先分离出包含“周”的字符串,然后通过 parseWeeks 函数将其转换为一个整数列表(例如 [1, 2, 3, 5, 7, 9])。
    • 最后,它为每个有效的周次都创建一个 Course 对象,并添加到列表中。在所有课程都被初步解析后,调用 Common.mergeWeekCourse 和自定义的 mergeAdjacentNodes 进行合并与优化。
  2. parseWeeks():这个辅助函数专门处理复杂的周次字符串。
    • 它能识别 1-16 这样的范围,也能识别 关键字,并据此过滤周次。
    • 例如,对于 "9-12(双)",它会先生成 [9, 10, 11, 12],然后因为有“双”字,只保留偶数,最终得到 [10, 12]
  3. extractRoom()extractNodeRange():这两个函数负责从混杂的字符串中提取有用的信息。
    • extractNodeRange 优先使用 data- 属性,如果不存在,则从文本中通过正则表达式匹配节次信息。
    • extractRoom 则通过排除法,剔除掉周次和节次信息后,将剩余部分作为教室地点。
  4. mergeAdjacentNodes():这是一个优化步骤。
    • 在处理完所有课程后,有些课程可能是连续的(例如,同一门课在第1-2节和第3-4节),这个函数会将它们合并成一个更长的节次(第1-4节),使课表更整洁。

2. 测试与验证:WISTTest.kt

编写完解析器后,如何验证它的正确性呢?这就需要一个测试文件,用于开发和调试阶段。

主要功能

  • 从本地读取保存的课表 HTML 文件。
  • 调用 WISTParser 进行解析。
  • 以多种格式清晰地打印解析结果。
  • 冲突检测:检查同一时间是否有两门或以上课程。
  • 汇总分析:统计每门课的上课周次、教室和总课时。
  • CSV 导出:将解析结果导出为 csv 文件,方便使用工具查看。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
package main.java.test

import main.java.parser.WISTParser
import bean.Course
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
import kotlin.system.measureTimeMillis

fun main() {
// ======================== [ ⚙️配置区 - 调试开关 ] ========================
val enableCourseDetailLog = true // 是否打印每门课的详细信息
val enableConflictCheck = true // 是否检查课程冲突
val enableWeekSummary = true // 是否汇总相同课程的所有周数
val enableRoomSummary = false // 是否统计课程使用的所有教室
val enableDurationSummary = false // 是否统计每门课总课时
val enableTimeSummary = true // 是否统计解析课表的总时间
// ======================== [ ⚙️CSV配置区 - 调试开关 ] ========================
val enableCsvExport = true // 是否启用 CSV 导出功能
// =====================================================================

// ======== 1. 读取 HTML 文件内容 ========
// 示例中用了相对路径,Windows 下可能需要修改
// 建议从项目外引用 html 文件
// 提交时一定不要上传 html 文件,涉及隐私问题
val htmlFilePath = "D:/Download/Programs/WC2025-05-31.html" // 请修改为你的HTML文件路径
val htmlContent = try {
File(htmlFilePath).readText()
} catch (e: IOException) {
println("❌ 无法读取 HTML 文件: ${e.message}")
return
}

println("🕒 当前时间:${SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Date())}")

// ======== 2. 初始化解析器并获取课程列表 ========
lateinit var courseList: List<Course>
val duration = measureTimeMillis {
try {
val parser = WISTParser(htmlContent)
courseList = parser.generateCourseList()
} catch (e: Exception) {
println("❌ 解析器异常:${e.message}")
return
}
}
if (courseList.isEmpty()) {
println("📭 未解析出任何课程,请检查 HTML 内容是否正确。")
return
}

if (enableCourseDetailLog) {
val groupedCourses = courseList.groupBy {
listOf(it.name, it.teacher, it.room, it.day, it.startNode, it.endNode)
}

println("\n📚 课程解析详情\n" + "-".repeat(50))
groupedCourses.values.forEachIndexed { index, group ->
val sample = group.first()

// 解析每个课程的周次
val weeks = group.flatMap { course ->
(course.startWeek..course.endWeek).toList()
}.toSet().sorted()

// 打印课程详情
println("🔹 第 ${index + 1} 门课".padEnd(30, '─'))
println("📓 课程名称 : ${sample.name}")
println("🧑🏻‍🏫 教师 : ${sample.teacher}")
println("📅 周数 : ${weeks.joinToString(", ")}")
println("⏰ 节次 : 第 ${sample.startNode} - ${sample.endNode} 节")
println("📆 星期 : 周 ${sample.day}")
println("📍 地点 : ${sample.room}")
println("📝 备注 : ${sample.note}")
println()
}
println("📥 共解析出 ${groupedCourses.size} 门课程。")
}

// ======== 4. 冲突检测(可配置) ========
if (enableConflictCheck) {
println("\n🔍 冲突检测结果\n" + "-".repeat(50))
val conflicts = mutableListOf<Pair<Course, Course>>()
for (i in courseList.indices) {
for (j in i + 1 until courseList.size) {
val a = courseList[i]
val b = courseList[j]

// 核心:比较是否为同一周、同一天、时间重叠
val sameWeek = (a.startWeek..a.endWeek).intersect(b.startWeek..b.endWeek).isNotEmpty()
val sameDay = a.day == b.day
val timeOverlap = a.startNode <= b.endNode && b.startNode <= a.endNode

if (sameWeek && sameDay && timeOverlap) {
conflicts += a to b
}
}
}

if (conflicts.isEmpty()) {
println("✅ 未发现课程冲突。😄")
} else {
println("⚠️ 共发现 ${conflicts.size} 处冲突😔:")
conflicts.forEachIndexed { idx, (c1, c2) ->
println("🆘 冲突 ${idx + 1}")
println("🅰️ ${c1.name} (周${c1.startWeek}-${c1.endWeek}, 周${c1.day}, 节${c1.startNode}-${c1.endNode})")
println("🅱️ ${c2.name} (周${c2.startWeek}-${c2.endWeek}, 周${c2.day}, 节${c2.startNode}-${c2.endNode})\n")
}
}
}

// ======== 5. 汇总分析:周数、教室、总课时 ========
if (enableWeekSummary || enableRoomSummary || enableDurationSummary) {
println("\n📊 课程汇总分析\n" + "-".repeat(50))

val weekMap = mutableMapOf<String, MutableSet<Int>>()
val roomMap = mutableMapOf<String, MutableSet<String>>()
val hourMap = mutableMapOf<String, Int>()

courseList.forEach { course ->
val key = "${course.name}__${course.teacher}"
val allWeeks = (course.startWeek..course.endWeek).toSet()
val room = course.room.trim()

// 判断是否是单双周
val weekType = course.type // 1为单双周,0为不处理单双周
val weeks = when (weekType) {
1 -> allWeeks.filter { it % 2 == 1 }.toSet() // 单周,保留奇数周
0 -> allWeeks // 不做单双周处理,保留所有周次
else -> allWeeks.filter { it % 2 == 0 }.toSet() // 默认处理为双周,保留偶数周
}

// 汇总上课周数
if (enableWeekSummary) {
weekMap.getOrPut(key) { mutableSetOf() }.addAll(weeks)
}

// 汇总教室信息
if (enableRoomSummary && room.isNotBlank()) {
roomMap.getOrPut(key) { mutableSetOf() }.add(room)
}

// 汇总课时数(按双节次为 1 节计)
if (enableDurationSummary) {
val duration = (course.endNode - course.startNode + 1) / 2
hourMap[key] = (hourMap[key] ?: 0) + duration * weeks.size
}
}

// 打印结果
weekMap.keys.union(roomMap.keys).union(hourMap.keys).forEach { key ->
val (name, teacher) = key.split("__")
println("📓 课程名 : $name")
println("🧑🏻‍🏫 教师 : $teacher")
if (enableWeekSummary) {
val weeks = weekMap[key]?.toList()?.sorted() ?: emptyList()
println("🌤️ 上课周 : $weeks")
println("🔢 周数段 : ${compactWeekList(weeks)}")
}
if (enableRoomSummary) {
val rooms = roomMap[key]?.toList()?.sorted() ?: emptyList()
println("🏫 教室列表 : ${rooms.joinToString("、")}")
}
if (enableDurationSummary) {
val totalHours = hourMap[key] ?: 0
println("⏱️ 总课时 : $totalHours 节")
}
println()
}

if (enableDurationSummary) {
println("🧮 所有课程合计上课节数:${hourMap.values.sum()} 节")
println("📌 注:每 2 个节次 node 计为 1 节课(如 node=1,2 为 1 节)\n")
}
}

if (enableTimeSummary) {
println("⏳ 本次课表汇总解析耗时:${duration}ms\n")
}
// CSV导出功能
if (enableCsvExport) {
print("📤 是否导出课程表为 CSV 到桌面?(Y/n):")
val answer = readLine()?.trim()?.lowercase()
if (answer.isNullOrEmpty() || answer == "y") {
try {
val desktopPath = System.getProperty("user.home") + "/Desktop"
val resultFolder = File(desktopPath, "解析结果")
if (!resultFolder.exists()) {
resultFolder.mkdirs()
}

// 使用大写HH表示24小时制
val date = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
val fileName = "课表导出_$date.csv"
val csvFile = File(resultFolder, fileName)

// 分组并整理为每组一行,包含所有周数
val grouped = courseList.groupBy {
listOf(it.name, it.teacher, it.room, it.day, it.startNode, it.endNode)
}

csvFile.printWriter().use { writer ->
writer.println("课程名称,星期,开始节数,结束节数,老师,地点,周数")

for ((_, group) in grouped) {
val sample = group.first()

// 根据 course.type 判断单双周
val validWeekList = group.flatMap { course ->
val allWeeks = (course.startWeek..course.endWeek).toSet()

// 判断单双周
when (course.type) {
1 -> allWeeks.filter { it % 2 == 1 } // 单周,保留奇数周
0 -> allWeeks // 不做单双周处理,保留所有周次
else -> allWeeks.filter { it % 2 == 0 } // 默认处理为双周,保留偶数周
}
}.toSet().sorted()

// 生成格式化的周次字符串
val weekTypeDesc = when (sample.type) {
1 -> when {
validWeekList.all { it % 2 == 1 } -> "(单周)"
validWeekList.all { it % 2 == 0 } -> "(双周)"
else -> ""
}
else -> ""
}
val weekStr = "${compactWeekList(validWeekList)} $weekTypeDesc".trim()

// 关键修复:用双引号包裹可能包含逗号的字段
val line = listOf(
escapeCsvField(sample.name),
sample.day.toString(),
sample.startNode.toString(),
sample.endNode.toString(),
escapeCsvField(sample.teacher),
escapeCsvField(sample.room),
escapeCsvField(weekStr)
).joinToString(",")

writer.println(line)
}
}

println("✅ CSV 导出成功!文件位置:${csvFile.absolutePath}")
} catch (e: Exception) {
println("❌ 导出失败:${e.message}")
}
} else {
println("📁 用户选择取消导出。")
}
}

println("🏁 测试结束:)\n" + "-".repeat(50))
}

/**
* 工具函数:
* 将整数周列表如 [1,2,3,5,6,9] 转换为格式化段落字符串 "1-3,5-6,9"
* 用于更美观地上课周数展示
*/
fun compactWeekList(weeks: List<Int>): String {
if (weeks.isEmpty()) return ""
val result = mutableListOf<String>()
var start = weeks[0]
var end = weeks[0]

for (i in 1 until weeks.size) {
if (weeks[i] == end + 1) {
end = weeks[i]
} else {
result += if (start == end) "$start" else "$start-$end"
start = weeks[i]
end = weeks[i]
}
}
result += if (start == end) "$start" else "$start-$end"
return result.joinToString(",")
}

// 新增:CSV字段转义函数,处理特殊字符
fun escapeCsvField(field: String): String {
// 如果字段包含逗号、引号或换行符,则用双引号包裹并转义内部引号
if (field.contains(",") || field.contains("\"") || field.contains("\n")) {
return "\"${field.replace("\"", "\"\"")}\""
}
return field
}

使用方法

  1. 在你的教务系统网页上,按 F12 打开开发者工具,选取课表区域然后保存网页为 HTML 文件。
  2. 修改 WISTTest.kt 文件中的 htmlFilePath 变量,使其指向你保存的 HTML 文件路径。
  3. 运行 main 函数,观察控制台的输出。
  4. 通过开关顶部的 enable... 布尔值变量,可以控制输出内容的详细程度,这在调试时非常有用。
    **注意:**当时测试时发现使用查看CSV是正常的,如果使用Excel会导致部分显示不正常错位,建议还是使用CSV软件来查看导出结果。

总结

自己动手适配教务系统虽然需要一些编程基础。核心在于分析 HTML 结构处理不规范的文本数据。只要掌握了 Jsoup 的基本用法和一些 Kotlin 的数据处理技巧,你也可以为自己的学校贡献一个适配器。

如果需要查看和WISTTest.ktWISTParser.kt原文件,可以前往我的复刻仓库搜索文件查看Qing90bing/CourseAdapter

希望这篇文章能对同样想进行适配的同学有所帮助!