All checks were successful
Build / build (push) Successful in 5m28s
Refactor the gRPC client and task handling logic to improve modularity, error handling, and observability. - Split `grpc/handler.go` into specialized handler files (login, classroom, exams, grades, schedule) to reduce complexity. - Replace standard `log` with `log/slog` throughout the project for structured logging. - Refactor `grpc/client.go` to use a thread-safe connection management pattern with `sync.RWMutex`. - Remove the legacy `service` package and HTTP server implementation, transitioning the application to a pure gRPC runner mode. - Clean up `config` and `pkg/errors` to remove unused utilities and simplify the API. - Improve error reporting in parsers and HTTP clients by checking response status codes.
469 lines
11 KiB
Go
469 lines
11 KiB
Go
package parser
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"schedule_converter/client"
|
||
"schedule_converter/config"
|
||
"schedule_converter/models"
|
||
|
||
"github.com/PuerkitoBio/goquery"
|
||
)
|
||
|
||
var weekTokenRegexp = regexp.MustCompile(`\[?\d+(?:[-,,]\d+)*\]?周`)
|
||
|
||
type weekRoomEntry struct {
|
||
weeksStr string
|
||
room string
|
||
}
|
||
|
||
// 获取课表
|
||
// FetchScheduleWithClient 使用指定的 HTTP 客户端和学期获取课表
|
||
func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models.Course, error) {
|
||
// POST请求获取课表
|
||
data := fmt.Sprintf("xnxq=%s", semester)
|
||
req, err := http.NewRequest("POST", config.Get().BaseURL+"/kbcx/queryGrkb", strings.NewReader(data))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
|
||
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return ParseScheduleHTML(string(body))
|
||
}
|
||
|
||
// 解析课表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
|
||
}
|