⌈产品⌋ 多源抓取、去重与索引的工程实践 x PKU7Day(北京大学自习教室规划与课程检索)

做这个项目有两个原因,一是看到了下图中的树洞帖子,回想起本科维护过的民间数学机考刷题网站;二是 AI 崛起,国产 Deepseek 成绩瞩目,想动手尝试一次尽量使用 AI 编写含前后端的项目。

alt text

课表查询

申请到的校园 API 信息量太少,难以实现检索,故只能从门户中寻找有用的接口。上课摸鱼了三天,一直在整理数据来源和思考这些数据如何整合,实现 1+1>21 + 1> 2

flowchart LR
    subgraph A[第一阶段:单源处理]
        A1[数据采集] --> A2[预处理与结构化]
    end

    subgraph B[第二阶段:多源整合]
        B1[以主体为核心] --> B2[特征匹配与交叉扩展]
    end

    A --提供--> B
    B --形成--> C1

    subgraph C[最终目标]
        direction LR
        C1[完整、一致的数据集]
        C1 --> D[数据建模]
        D --> E[✅ 支持高效查询与分析]
    end

数据采集与预处理流程

flowchart LR
    A[选定目标数据源<br>网页/API] --> B{评估稳定性与可靠性}
    B -- 不可靠 --> A
    B -- 可靠 --> C[爬取数据]
    C --> D[原始数据<br>(JSON/HTML/文本等)]
    D --> E{数据是否为非结构化文本?}
    E -- 是 --> F[使用正则表达式等方法<br>提取与分割关键信息]
    E -- 否 --> G[直接解析结构化的数据<br>(如JSON)]
    F --> H[初步结构化数据]
    G --> H
flowchart LR
    A[开始爬取任务] --> B[读取爬取目标参数<br>e.g. URL, 接口, 日期]
    B --> C{查询数据库记录<br>该目标是否已爬取过?}

    C -- 未爬取过 --> D[执行爬取任务]
    C -- 已存在记录 --> E{强制更新?}

    E -- 是 --> D
    E -- 否 --> G[跳过爬取<br>使用现有数据]

    D --> H[保存/覆盖原始数据至<br>'源数据存储']

    H --> I[更新或插入爬取记录至<br>'爬取记录表'<br>记录目标参数、时间、状态等]
    I --> J[结束本次任务]
    G --> J

多源数据交叉扩展流程

flowchart LR
    A[初始数据集合<br>(以某一主体为核心)] --> B(提取关键特征<br>如ID、名称、时间等)

    subgraph S[多源数据扩展]
        direction LR
        B --> C[根据特征匹配数据源2]
        B --> D[根据特征匹配数据源3]
        B --> E[根据特征匹配数据源...]
        C -- 补充信息 --> F[扩展后的数据记录]
        D -- 补充信息 --> F
        E -- 补充信息 --> F
    end

    F --形成--> Z[更完整的数据记录]

数据存储与查询

flowchart LR
    A[完整数据] --> B[数据建模]
    B --> C[映射与保存至数据库]

    subgraph C [数据库表]
        direction LR
        T1[Teacher]
        T2[Room]
        T3[Semester]
        T4[...]
    end

    C --> D[利用外键关联<br>支持高效复杂查询]
    C --> E[快速列举特征<br>前端支持选择特征]

在构造复杂查询函数时,AI 表现不佳,很像刚学数据库的小白。

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
async searchCoursesInner(searchDto: SearchCourseDto): Promise<Course[]> {
const query = this.courseRepository
.createQueryBuilder('course')
.leftJoinAndSelect('course.courseOfferings', 'courseOffering')
.leftJoinAndSelect('courseOffering.department', 'department')
.leftJoinAndSelect('courseOffering.academicYear', 'academicYear')
.leftJoin('courseOffering.semester', 'semester')
.addSelect(['semester.id', 'semester.type'])
.leftJoin('courseOffering.instructors', 'instructor')
.addSelect(['instructor.id', 'instructor.name'])
.leftJoin('courseOffering.classSessions', 'classSession')
.addSelect(['classSession.id', 'classSession.dayOfWeek', 'classSession.startSection', 'classSession.endSection', 'classSession.startWeek', 'classSession.endWeek'])
.leftJoin('classSession.room', 'room')
.addSelect(['room.id', 'room.name'])
.leftJoin('classSession.relateRooms', 'relateRoom')
.addSelect(['relateRoom.id', 'relateRoom.name']);

// 处理课程名称搜索
if (searchDto.title) {
query.leftJoin('course.detail', 'detail')
.addSelect(['detail.englishName'])
query.andWhere('(course.name LIKE :title OR detail.englishName LIKE :title)', {
title: `%${searchDto.title}%`
});
}

const applyCourseOfferingConditions = (
qb: WhereExpressionBuilder,
searchDto: SearchCourseDto
) => {
// 院系条件
if (searchDto.departmentId) {
qb.andWhere('courseOffering.departmentId = :departmentId', {
departmentId: searchDto.departmentId
});
}

// 教师姓名精确匹配
if (searchDto.instructorId) {
qb.andWhere('instructor.id = :instructorId', {
instructorId: searchDto.instructorId
});
}

// 学年条件
if (searchDto.academicYearId) {
qb.andWhere('courseOffering.academicYearId = :academicYearId', {
academicYearId: searchDto.academicYearId
});
}

// 学期条件
if (searchDto.semesterId) {
qb.andWhere('courseOffering.semesterId = :semesterId', {
semesterId: searchDto.semesterId
});
}
}

const applyClassSessionConditions = (
qb: WhereExpressionBuilder,
searchDto: SearchCourseDto
) => {
// 教室条件(修复relateRooms关联问题)
if (searchDto.roomId !== undefined) {
qb.andWhere(
new Brackets(subQb => {
subQb.where('room.id = :roomId', {
roomId: searchDto.roomId
})
.orWhere('relateRoom.id = :roomId', {
roomId: searchDto.roomId
});
})
);
}

// 周次范围
if (searchDto.weeks?.length === 2) {
const [startWeek, endWeek] = searchDto.weeks;
qb.andWhere('classSession.startWeek >= :startWeek', {startWeek})
.andWhere('classSession.endWeek <= :endWeek', {endWeek});
}

// 节次范围
if (searchDto.sections?.length === 2) {
const [startSection, endSection] = searchDto.sections;
qb.andWhere('classSession.startSection >= :startSection', {startSection})
.andWhere('classSession.endSection <= :endSection', {endSection});
}

// 星期几条件
if (searchDto.dayOfWeek !== undefined) {
qb.andWhere('classSession.dayOfWeek = :dayOfWeek', {
dayOfWeek: searchDto.dayOfWeek
});
}
}

// 构建复杂查询条件
query.andWhere(
new Brackets(qb => {
// 二级条件(CourseOffering级别)
applyCourseOfferingConditions(qb, searchDto);

// 三级条件(ClassSession级别)
applyClassSessionConditions(qb, searchDto);
})
);

return query.getMany();
}

alt text
alt text

空闲教室规划

使用类似方法获取和更新空闲教室数据。
给定时间段[s,t][s, t],从教室集合中选择 NN 条路径(可更换教室),使得总使用时间覆盖[s,t][s, t],且移动距离最小。算法通过启发式搜索(优先队列 + BFS)实现。

flowchart TD
    A[开始] --> B[预处理教室空闲时间段排序]
    B --> C[初始化优先队列]
    C --> D{优先队列是否为空?}
    D -- 是 --> E[输出解(反转解队列)]
    D -- 否 --> F[弹出当前状态<br>count, distance, endTime, path]
    F --> G{检查剪枝条件<br>已有10个解且当前距离>堆顶距离?}
    G -- 是 --> D
    G -- 否 --> H{endTime >= t?}
    H -- 是 --> I[加入解队列并保持最多10个解]
    I --> D
    H -- 否 --> J[尝试延续当前教室]
    J --> K[生成新路径并加入队列]
    H -- 否 --> L[尝试切换教室]
    L --> M[生成新路径并加入队列]
    K --> D
    M --> D

alt text

数据库表

使用 TypeORM 同步和操作数据库。无论是 ChatGPT 还是 DS,都能很好地完成任务。
alt text

起名与域名注册

在 AI 的帮助下,构建它只花了一周,所以起名为 7Day。
alt text

部署

部署在校内 CLab。
alt text
alt text

更新日志

2025-09-16

  1. 🐞 修复无法按照校区、教学楼、楼层检索课程的问题(错误将区域 ID 当作教室 ID,ANTD 级联未设置只允许勾选叶子节点)。 @安茗炫
  2. 🆕 支持多关键词搜索课程中英文名称、描述。
  3. 💄 更新二维码、关于我们以及首页文案。
  4. 💄 更新项目依赖。