《从零开发校园墙微信小程序》进阶篇之六(完结篇)。前两篇我们成功拿回了教务系统的课表页面。这一篇把那一大坨 HTML 变成结构化的数据,并解决课表里最绕的几个概念:学期、教学周、单双周、调课与放假。

拿到的是什么

教务返回的课表是一个 HTML 表格:

  • 每一行 是一个节次(第一节课、第二节课……),行首的 <th> 是节次名;
  • 每一列 是星期一到星期日;
  • 每个单元格 里有一个或多个 <div class="kbcontent...">,每个 div 是一门课,格式大致是:
课程名 <br> <font title="周次">1-17(周)</font> <br> <font title="教室">…</font> <br> <font title="老师">…</font>

目标是把它变成一行行 Schedules 记录:

@TableName("schedules")
public class Schedules {
    private Integer id;
    private String studentid;   // 学号
    private String time;        // 节次,如「第一节课 08:20-09:05」
    private String name;        // 课程名
    private Integer weeks;      // 周次位掩码(见下文)
    private String dayofweek;   // 星期一 ~ 星期日
    private String classroom;
    private String teacher;
    private String semester;    // 学期,如 2025-2026-1
}

用 Jsoup 解析

HTML 解析用 Jsoup 。整体是三层循环:

Document doc = Jsoup.parse(courseHtml);
for (Element row : doc.select("table tr")) {
    Element timeElement = row.selectFirst("th");
    if (timeElement == null) continue;                    // 非数据行(如表头)
    String time = enrichTime(timeElement.text());
    Elements cells = row.select("td");
    for (int i = 0; i < cells.size() && i < DAYS_OF_WEEK.length; i++) {   // i 上限保护
        for (Element courseDiv : cells.get(i).select("div[class^=kbcontent]")) {
            if (courseDiv.id().endsWith("-1")) continue;   // 教务系统的无效数据标记
            ...
        }
    }
}

这段代码里的每一个防御都对应一个真实踩过的坑 ,类上的注释把它们列得很清楚:

  1. 周次字符串不匹配时返回null 导致 NPE → 现在返回空位掩码 0;
  2. 1,3-5 这种混合周次格式解析抛 NumberFormatException;
  3. 不支持单双周 1-17(单) / 1-16(双);
  4. td 下标越界导致星期错位 / 数组越界 —— 所以循环里加了 i < DAYS_OF_WEEK.length 的上限保护;
  5. 正则在循环内重复编译、contains 去重是 O(n²)、清理 span 时没开 DOTALL。

教训是:解析外部 HTML 要把「它不按你想的来」当成常态。 每一个假设(下标、格式、非空)都要有兜底,而不是默认页面永远整齐。

选择器的小技巧

div[class^=kbcontent] 用的是属性前缀选择器 :只要 class 以 kbcontent 开头就匹配。因为教务页面里同一类元素的 class 可能是 kbcontent 也可能是 kbcontent1 之类,用前缀匹配比精确匹配更稳。

多门课挤在同一个格子

同一个节次、同一个星期,可能有多门课(比如不同周次上不同的课)。教务会用一条长分隔线把它们隔开:

private static final Pattern MULTI_COURSE_SEPARATOR = Pattern.compile("-{21,23}");   // 判断是否有多门课
private static final Pattern SPLIT_SEPARATOR = Pattern.compile("\\s*-{5,}\\s*");     // 拆分用

先用「21~23 个短横线」判断这个格子里是不是多门课,是的话再按「5 个以上短横线」拆开,逐门解析。拆完要处理残留:除第一段外,段首可能留着一个多余的 <br>,要去掉。

去重

同一门课可能在表格里出现多次(比如连上两节,每个节次各出现一次)。解析结果用 HashSet 去重,前提是实体类实现了 equals / hashCode:

// Schedules 已实现 equals/hashCode,用 Set 做 O(1) 去重
Set<Schedules> seen = new HashSet<>();
...
if (course != null && seen.add(course)) courses.add(course);

旧版用 list.contains() 去重,是 O(n²),课多的时候会明显变慢。

节次时间:补齐缺失的时刻

教务页面的 <th> 以前会带具体时刻(第一节课 08:20-09:05),后来改版只给节次标签(第一节课)。但库里已有大量老数据是带时刻的格式。为了新旧数据格式一致 ,解析器按学校统一的作息表补齐:

private static final Map<String, String> PERIOD_TIME_MAP = Map.ofEntries(
    Map.entry("第一节课", "08:20-09:05"),
    Map.entry("第二节课", "09:10-09:55"),
    ...
    Map.entry("中午休息时间", "12:00-14:10"),
    ...
);

private static String enrichTime(String time) {
    time = time.trim();
    if (time.isEmpty() || CLOCK_TIME_PATTERN.matcher(time).find()) return time;   // 已含时刻,原样返回
    String clock = PERIOD_TIME_MAP.get(time);
    return clock != null ? time + " " + clock : time;
}

这张表不是拍脑袋写的:注释里说,已经对照数据库里 622 条老数据验证过——同一节次在所有学号、所有星期下,时刻完全一致 ,才敢直接按节次补。
还有个有意思的细节:「中午休息时间」也在表里 ,因为库里确实有课程被安排在这个时段(比如周三 12:00–14:10 的某门课)。别想当然地把它当成纯分隔行丢掉。
normalizeTime 和 periodTimeMap 还对外暴露出来,供管理后台手工编辑课表 时使用(下拉选项、规范化),保证人工录入和自动采集的数据格式一致。

周次:用位掩码存储

这是课表数据设计里最漂亮的一个点。一门课的上课周次可能是:5、1-17、1,3,5、1,3-5,8、1-17(单)、1-16(双)……
怎么存?如果存字符串,每次前端判断「第 7 周有没有这门课」都要重新解析,还容易写错。项目里把周次存成一个整数位掩码 (bitmask):第 N 周上课,就把第 N 位设为 1。

public static int weeksToBitmask(List<Integer> weeks) {
    int bitmask = 0;
    for (int week : weeks) bitmask |= 1 << (week - 1);
    return bitmask;
}

public static List<Integer> bitmaskToWeeks(int bitmask) {
    List<Integer> weeks = new ArrayList<>();
    for (int i = 0; i < 32; i++) {
        if ((bitmask & (1 << i)) != 0) weeks.add(i + 1);
    }
    return weeks;
}

例如第 1、3、5 周上课:

第5周 第4周 第3周 第2周 第1周
  1     0     1     0     1   = 二进制 10101 = 十进制 21

判断「第 7 周有没有课」只需一次位运算:(weeks & (1 << 6)) != 0,不需要任何字符串解析。
好处 :一个 int 存下整学期(最多 32 周,课表场景足够);判断「某周有没有课」是 O(1);单双周、不连续周次都用统一的方式表达,数据库里存的就是个数字,体积小、索引友好。

注意 week 的范围是 1 ~ 32。超过 32 周会溢出,不过没有学期会有这么多周。

周次字符串解析

字符串到周次列表,用一个正则抓出数字串和可选的单双标记:

// 如 "1-17(周)"、"1-16(双)"、"1,3-5,8"
Pattern WEEK_PATTERN = Pattern.compile("(\\d+(?:-\\d+)?(?:,\\d+(?:-\\d+)?)*)\\s*(?:\\((单|双)\\))?");

然后按逗号拆开,每段要么是单个数字,要么是 a-b 区间:

for (int w = Math.max(1, start); w <= end; w++) {
    // 单周=奇数周,双周=偶数周
    if ("单".equals(parity) && w % 2 == 0) continue;
    if ("双".equals(parity) && w % 2 == 1) continue;
    weeks.add(w);
}

两个防御:每个片段的数字解析都包在 try / catch (NumberFormatException) 里,一个片段坏了不影响其他片段 ;整体解析失败返回空列表 (位掩码 0),不抛异常 。一门课周次解析失败,最多是这门课显示不出来,绝不能让整份课表都解析失败。

学期与教学周

课表按学期保存。「学期」用 2025-2026-1 这样的字符串表示:学年 + 第几学期(1 = 秋季,2 = 春季)。当前是哪个学期,由当前月份 推导:

public static String getCurrentSemester() {
    LocalDate now = LocalDate.now();
    int year = now.getYear();
    if (month >= 9 && month <= 12) return year + "-" + (year + 1) + "-1";   // 9~12 月:本学年第 1 学期
    else if (month == 1)           return (year - 1) + "-" + year + "-1";   // 1 月:上一学年第 1 学期
    else if (month >= 2 && <= 6)   return (year - 1) + "-" + year + "-2";   // 2~6 月:上一学年第 2 学期
    else /* 7~8 月 */              return year + "-" + (year + 1) + "-1";   // 暑假:提前显示下一学年第 1 学期
}

然后是「现在是第几周 」。这需要知道学期的开课日期 。项目的做法是分层兜底 :

  1. 数据库里有这个学期的开课日期,以它为准 (并规整到最近的周一);
  2. 没有,就按学期名推导一个基准日 :秋季默认 9 月 1 日,春季默认 3 月 1 日(春节日期浮动,没有固定规律,只能兜底);推导出来后自动插入一条记录 ,之后在数据库里直接改就行,不用发版。

基准日不一定是周一,所以要找「最近的周一」 :

/**
 * 取某日期最近的那个周一:周四及以前往前取,周五及以后往后取。
 * 例如 9月1日是周日 -> 9月2日(周一);9月1日是周二 -> 8月31日(周一)。
 */
public static LocalDate nearestMonday(LocalDate date) {
    LocalDate prev = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
    LocalDate next = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
    long diffPrev = Math.abs(ChronoUnit.DAYS.between(date, prev));
    long diffNext = Math.abs(ChronoUnit.DAYS.between(date, next));
    return diffPrev <= diffNext ? prev : next;
}

用 java.time 的 TemporalAdjusters 比自己算日期可靠得多。开课基准日的两个默认值还做成了配置项 (semester.fall-start / semester.spring-start)。
这里体现的是一种「默认值 + 可覆盖」的思路 :规律能推导的先推导出来保证有值;规律不固定的(春季开课日),留一个数据库里可以直接改的口子。

服务端返回整学期,前端按周过滤

获取课表接口的注释里有一个设计决定值得讲:

// 返回全学期课表:前端按 viewWeek(当前周+周偏移)本地过滤 weeks 位掩码并支持整学期翻周,
// 服务端若按当前周过滤会把只上稀疏周的课(如形策第3/8/13周)在非当周时整条滤掉,翻周永远看不到
return schedulesRepository.findScheduleByOpenid(openid, semesterStr).collectList()
        .map(result -> new Result(200, "获取课表成功", result));

最初的想法是「服务端按当前周过滤」,数据量小。但这样用户翻到别的周就看不到那些只在稀疏周上课的课 (比如只在第 3、8、13 周上的课程)。所以改成:服务端返回全学期,前端用位掩码本地过滤、自由翻周 。位掩码正好让前端的这个过滤又快又简单。

校历调整:调课与放假

国内学校总有「调休补课」和「放假」:比如某周六要补周一的课,国庆中秋假期不上课。这些不在课表 HTML 里 ,要另外维护。
项目里有一张 calendar_adjust 表,两种类型:

类型含义字段
makeup补课:某天按「被补的星期 + 被补的教学周」显示课表日期、目标星期、目标周、说明
break放假:区间内不显示课程开始日、结束日、假期名称

放假区间用半开区间[start, end),结束日当天复课,避免「结束日算不算放假」的歧义;end 缺省视为次日复课。
接口把它们整理后返回给前端,前端据此渲染:调课日显示被补的那一天的课程,放假期间不显示课程,还能根据假期名称显示节日问候。

内容指纹:让客户端知道「校历变了没有」

// 内容指纹:任一条目被增删改都会变化,前端据此判断"校历有没有变"(不必轮询全量比对)
String version = String.join("|", list.stream()
        .map(i -> String.join(":", String.valueOf(i.getId()), String.valueOf(i.getAdjusttype()),
                String.valueOf(i.getDatestart()), String.valueOf(i.getDateend()),
                String.valueOf(i.getTargetdayofweek()), String.valueOf(i.getTargetweek()),
                String.valueOf(i.getLabel())))
        .toList());
data.put("version", version.isEmpty() ? "empty" : version);

把每一条校历的关键字段拼成一个版本指纹 。前端缓存了校历,下次只需要比较一下 version 字段是否变化,就知道要不要刷新,而不用把全量数据逐条比较。注意连 label(假期名称)也参与了指纹——因为只改名称也要让客户端刷新 。
最关键的是:校历存在数据库里,改库即对所有客户端生效,无需发版。 小程序发版要走审核,节假日调整却是每个学期都不一样的,所以必须是数据驱动的。

小结

  1. 解析课表:Jsoup 三层循环,每个假设都要有兜底 (下标、格式、非空),多门课拆分、HashSet 去重;
  2. 页面改版后缺失的字段,用已验证的映射表 补齐,保证新旧数据格式一致;
  3. 周次用位掩码 :一个 int 存整学期,判断某周是否有课是 O(1),单双周统一表达;
  4. 解析失败要局部失败不扩散 :一个片段失败只影响自己,整体失败返回空而不是抛异常;
  5. 学期由月份推导,开课日数据库优先、推导兜底、自动落库可手改 ;找最近周一用 TemporalAdjusters;
  6. 服务端返回全学期,前端按位掩码本地过滤 ,才能自由翻周;
  7. 校历调整(调课 / 放假)数据驱动 ,用半开区间,用内容指纹 让客户端感知变化。

系列完结

到这里,「培正森林」从数据库到登录、投稿、敏感词、图片、微博开放平台、课表、再到部署上线,主线都讲完了。回头看,这些文章里反复出现的是同几条经验:

  • 把失败分类 :确定性 vs 瞬态,写入、重试、降级都建立在这个分类之上;
  • 降级要分层 ,并且协议要能区分「失败」和「空」;
  • 默认值 + 可覆盖 :数据驱动的配置,让运营不用发版;
  • 外部系统一定会出乎意料 :超时、幂等、去重、限速、白名单,一个都不能少;
  • 把血泪教训写进注释和文档 ,因为一年后的你,已经不记得为什么要这样写了。

谢谢你读到最后。如果你也在做类似的校园小程序,希望这个系列能帮你少踩几个坑。

最后修改:2026 年 11 月 12 日
如果觉得我的文章对你有用,请随意赞赏