All checks were successful
Build / build (push) Successful in 2m17s
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.
438 lines
10 KiB
Go
438 lines
10 KiB
Go
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 == " " {
|
||
continue
|
||
}
|
||
|
||
// 跳过 
|
||
if strings.TrimSpace(cellHTML) == " " {
|
||
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-6,0=周一,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
|
||
}
|