Files
schedule_converter/parser/schedule.go
lafay ab37aeb2fc
All checks were successful
Build / build (push) Successful in 2m17s
refactor: introduce service layer and migrate gRPC handlers to use it
Extract business logic from gRPC handlers into a dedicated service package.
Add context support throughout for cancellation and timeouts. Move models to
their own package, remove hardcoded credentials from config, and simplify
parsers to only handle HTML parsing.
2026-06-16 18:33:13 +08:00

438 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package parser
import (
"regexp"
"sort"
"strconv"
"strings"
"schedule_converter/models"
"github.com/PuerkitoBio/goquery"
)
var weekTokenRegexp = regexp.MustCompile(`\[?\d+(?:[-,]\d+)*\]?周`)
type weekRoomEntry struct {
weeksStr string
room string
}
// 解析课表HTML
func ParseScheduleHTML(html string) ([]models.Course, error) {
// 创建一个带Reader的Document
reader := strings.NewReader(html)
doc, err := goquery.NewDocumentFromReader(reader)
if err != nil {
return nil, err
}
courseMap := make(map[string]*models.Course)
// 找到课表table
doc.Find("table").Each(func(tableIdx int, table *goquery.Selection) {
class, _ := table.Attr("class")
if !strings.Contains(class, "addlist") {
return
}
table.Find("tr").Each(func(trIdx int, tr *goquery.Selection) {
tds := tr.Find("td")
if tds.Length() < 3 {
return
}
// 第一个td是上午/下午/晚上,第二个是节次
section := tds.Eq(1).Text()
if section == "" {
return
}
// 从第3个td开始是星期一到星期日 (索引2-8)
for dayIndex := 2; dayIndex <= 8; dayIndex++ {
if dayIndex >= tds.Length() {
break
}
dayTd := tds.Eq(dayIndex)
// 获取HTML内容
cellHTML, err := dayTd.Html()
if err != nil || cellHTML == "" || cellHTML == "&nbsp" {
continue
}
// 跳过&nbsp
if strings.TrimSpace(cellHTML) == "&nbsp" {
continue
}
day := getDayName(dayIndex - 1)
// 解析课程信息
parseCourseCellHTML(cellHTML, day, section, courseMap)
}
})
})
// 将map转为slice
var courses []models.Course
for _, course := range courseMap {
courses = append(courses, *course)
}
return courses, nil
}
// 解析课程单元格HTML
func parseCourseCellHTML(cellHTML, day, section string, courseMap map[string]*models.Course) {
// 检查是否有多个课程(用<br/>分割)
if strings.Contains(cellHTML, "<br>") || strings.Contains(cellHTML, "<br/>") || strings.Contains(cellHTML, "<br />") {
// 分割成多个课程
// 先统一替换
cellHTML = strings.ReplaceAll(cellHTML, "<br>", "|SPLIT|")
cellHTML = strings.ReplaceAll(cellHTML, "<br/>", "|SPLIT|")
cellHTML = strings.ReplaceAll(cellHTML, "<br />", "|SPLIT|")
courses := strings.Split(cellHTML, "|SPLIT|")
var courseName string
for i, part := range courses {
part = strings.TrimSpace(part)
if part == "" {
continue
}
beforeWeek, weekEntries := extractWeekEntries(part)
if len(weekEntries) == 0 {
// 没有周数信息,这是课程名(教室会在周数后面或下一个部分)
courseName = part
continue
}
// 有周数信息,说明这行包含老师+周数+地点
// 需要结合之前的课程名
if courseName == "" {
courseName = part
}
// 先取片段中的第一个有效教室作为回退值
fallbackRoom := ""
for _, entry := range weekEntries {
if entry.room != "" {
fallbackRoom = entry.room
break
}
}
// 如果片段里没有教室,查看下一个部分是否是教室
if fallbackRoom == "" {
// 找下一个部分
for j := i + 1; j < len(courses); j++ {
nextPart := strings.TrimSpace(courses[j])
if nextPart == "" {
continue
}
// 检查下一个部分是否是教室(包含楼/室/厅/院)
if strings.Contains(nextPart, "楼") || strings.Contains(nextPart, "室") || strings.Contains(nextPart, "厅") || strings.Contains(nextPart, "院") {
fallbackRoom = nextPart
break
}
// 如果下一个部分不包含周数信息,说明是课程名,停止查找
if !regexp.MustCompile(`\d+.*周`).MatchString(nextPart) {
break
}
}
}
// 解析老师在beforeWeek中
teacher := ""
// 去掉课程名部分
if strings.HasPrefix(beforeWeek, courseName) {
teacher = strings.TrimSpace(beforeWeek[len(courseName):])
} else {
// 老师可能直接在整个字符串中
teacherPattern := regexp.MustCompile(`([一-龥]{2,4})$`)
teacherMatch := teacherPattern.FindStringSubmatch(beforeWeek)
if teacherMatch != nil {
teacher = teacherMatch[1]
}
}
// 解析节次
sectionNums := parseSection(section)
// 每个“周次-地点”片段分别入库,避免不同周次地点混淆
for _, entry := range weekEntries {
room := strings.TrimSpace(entry.room)
if room == "" {
room = fallbackRoom
}
weeks := parseWeeks(entry.weeksStr)
if len(weeks) == 0 {
continue
}
// 使用课程名+老师+教室作为 key避免不同地点被错误合并
key := buildCourseKey(courseName, teacher, room)
// 检查是否已存在
if existing, exists := courseMap[key]; exists {
// 添加新的上课时间
existing.Schedule = append(existing.Schedule, models.Schedule{
Day: day,
Section: sectionNums,
Weeks: weeks,
})
if existing.Room == "" && room != "" {
existing.Room = room
}
} else {
// 创建新课程
courseMap[key] = &models.Course{
Name: courseName,
Teacher: teacher,
Room: room,
Schedule: []models.Schedule{
{
Day: day,
Section: sectionNums,
Weeks: weeks,
},
},
}
}
}
// 这个课程处理完后重置courseName
courseName = ""
}
} else {
// 只有一个课程,直接解析
line := strings.TrimSpace(cellHTML)
beforeWeek, weekEntries := extractWeekEntries(line)
if len(weekEntries) == 0 {
return
}
fallbackRoom := ""
for _, entry := range weekEntries {
if entry.room != "" {
fallbackRoom = entry.room
break
}
}
// 从beforeWeek中提取老师和课程名
teacherPattern := regexp.MustCompile(`([一-龥]{2,4})$`)
teacherMatch := teacherPattern.FindStringSubmatch(beforeWeek)
var courseName, teacher string
if teacherMatch != nil {
teacher = teacherMatch[1]
courseName = strings.TrimSpace(beforeWeek[:len(beforeWeek)-len(teacher)])
} else {
courseName = beforeWeek
}
if courseName == "" {
return
}
// 解析节次
sectionNums := parseSection(section)
for _, entry := range weekEntries {
room := strings.TrimSpace(entry.room)
if room == "" {
room = fallbackRoom
}
weeks := parseWeeks(entry.weeksStr)
if len(weeks) == 0 {
continue
}
// 使用课程名+老师+教室作为 key避免不同地点被错误合并
key := buildCourseKey(courseName, teacher, room)
if existing, exists := courseMap[key]; exists {
existing.Schedule = append(existing.Schedule, models.Schedule{
Day: day,
Section: sectionNums,
Weeks: weeks,
})
if existing.Room == "" && room != "" {
existing.Room = room
}
} else {
courseMap[key] = &models.Course{
Name: courseName,
Teacher: teacher,
Room: room,
Schedule: []models.Schedule{
{
Day: day,
Section: sectionNums,
Weeks: weeks,
},
},
}
}
}
}
}
func buildCourseKey(name, teacher, room string) string {
return strings.TrimSpace(name) + "|" + strings.TrimSpace(teacher) + "|" + strings.TrimSpace(room)
}
// extractWeekEntries 将一段文本拆成多个“周次-地点”片段
// 例如:李鹏程[2-8]周N楼-335[9]周N楼-331
// 会拆成:
// 1) weeks=[2-8]周 room=N楼-335
// 2) weeks=[9]周 room=N楼-331
func extractWeekEntries(text string) (beforeWeek string, entries []weekRoomEntry) {
indexes := weekTokenRegexp.FindAllStringIndex(text, -1)
if len(indexes) == 0 {
return "", nil
}
firstStart := indexes[0][0]
beforeWeek = strings.TrimSpace(text[:firstStart])
for i, idx := range indexes {
start := idx[0]
end := idx[1]
nextStart := len(text)
if i+1 < len(indexes) {
nextStart = indexes[i+1][0]
}
weeksStr := strings.TrimSpace(text[start:end])
room := strings.TrimSpace(text[end:nextStart])
room = weekTokenRegexp.ReplaceAllString(room, "")
room = strings.Trim(room, ", ")
room = strings.TrimSpace(room)
entries = append(entries, weekRoomEntry{
weeksStr: weeksStr,
room: room,
})
}
return beforeWeek, entries
}
func getDayName(index int) string {
// 返回0-60=周一6=周日
days := []string{"0", "1", "2", "3", "4", "5", "6"}
if index >= 1 && index <= 7 {
return days[index-1]
}
return ""
}
func parseSection(section string) []int {
// 格式: 1-2 或 3-4
re := regexp.MustCompile(`(\d+)-(\d+)`)
match := re.FindStringSubmatch(section)
if match != nil {
start, _ := strconv.Atoi(match[1])
end, _ := strconv.Atoi(match[2])
var nums []int
for i := start; i <= end; i++ {
nums = append(nums, i)
}
return nums
}
// 单独数字
num, err := strconv.Atoi(section)
if err == nil {
return []int{num}
}
return []int{}
}
func parseWeeks(weeksStr string) []int {
if weeksStr == "" {
return []int{}
}
// 统一格式:去掉括号/周/空白,统一中文分隔符
raw := strings.TrimSpace(weeksStr)
oddOnly := strings.Contains(raw, "单")
evenOnly := strings.Contains(raw, "双")
replacer := strings.NewReplacer(
"[", "", "]", "",
"", "", "", "",
"(", "", ")", "",
"周", "",
"", ",",
"、", ",",
" ", "",
)
normalized := replacer.Replace(raw)
if normalized == "" {
return []int{}
}
weekSet := make(map[int]struct{})
for _, part := range strings.Split(normalized, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// 区间格式1-8
if strings.Contains(part, "-") {
rangeParts := strings.SplitN(part, "-", 2)
if len(rangeParts) != 2 {
continue
}
start, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
end, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
if err1 != nil || err2 != nil || start > end {
continue
}
for i := start; i <= end; i++ {
if oddOnly && i%2 == 0 {
continue
}
if evenOnly && i%2 != 0 {
continue
}
weekSet[i] = struct{}{}
}
continue
}
// 单周次:例如 9
num, err := strconv.Atoi(part)
if err != nil {
continue
}
if oddOnly && num%2 == 0 {
continue
}
if evenOnly && num%2 != 0 {
continue
}
weekSet[num] = struct{}{}
}
if len(weekSet) == 0 {
return []int{}
}
weeks := make([]int, 0, len(weekSet))
for w := range weekSet {
weeks = append(weeks, w)
}
sort.Ints(weeks)
return weeks
}