diff --git a/grpc/client.go b/grpc/client.go index 8100dcc..b053e85 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -78,6 +78,8 @@ func (c *Client) register() error { Version: c.version, Capabilities: map[string]string{ "TASK_TYPE_GET_SCHEDULE": "true", + "TASK_TYPE_GET_GRADES": "true", + "TASK_TYPE_GET_EXAMS": "true", "TASK_TYPE_LOGIN": "true", }, }, diff --git a/grpc/handler.go b/grpc/handler.go index 2dc50ba..7c09bb8 100644 --- a/grpc/handler.go +++ b/grpc/handler.go @@ -8,6 +8,8 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "strconv" + "strings" "sync" "time" @@ -52,6 +54,10 @@ func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult { switch task.Type { case pb.TaskType_TASK_TYPE_GET_SCHEDULE: data, err = h.getSchedule(context.Background(), task.Payload) + case pb.TaskType_TASK_TYPE_GET_GRADES: + data, err = h.getGrades(context.Background(), task.Payload) + case pb.TaskType_TASK_TYPE_GET_EXAMS: + data, err = h.getExams(context.Background(), task.Payload) case pb.TaskType_TASK_TYPE_LOGIN: data, err = h.login(context.Background(), task.Payload) default: @@ -220,6 +226,86 @@ func executeLoginAndFetchSchedule(ctx context.Context, httpClient *http.Client, return parser.FetchScheduleWithClient(httpClient, semester) } +func (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, error) { + var req pb.GetGradesPayload + if err := json.Unmarshal(payload, &req); err != nil { + return nil, apperr.New("parse_payload", err, "解析任务载荷失败") + } + + log := slog.With("username", req.Username, "semester", req.Semester) + log.Info("获取成绩任务") + + httpClient, err := createHTTPClient() + if err != nil { + return nil, err + } + + cfg := config.Get() + setTWFIDCookie(httpClient, cfg.BaseURL, "") + + if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { + return nil, err + } + + _, grades, err := parser.FetchGPA(httpClient) + if err != nil { + log.Warn("获取GPA页面失败,尝试成绩页面", "error", err) + grades, err = parser.FetchGrades(httpClient, parser.ScoreTypeFinal) + if err != nil { + return nil, apperr.New("fetch_grades", err, "获取成绩失败") + } + } + + pbGrades := convertGrades(grades) + + result := &pb.GradesResultData{ + StudentId: req.Username, + Semester: req.Semester, + Grades: pbGrades, + } + + log.Info("成绩获取成功", "grade_count", len(pbGrades)) + return json.Marshal(result) +} + +func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, error) { + var req pb.GetExamsPayload + if err := json.Unmarshal(payload, &req); err != nil { + return nil, apperr.New("parse_payload", err, "解析任务载荷失败") + } + + log := slog.With("username", req.Username, "semester", req.Semester) + log.Info("获取考试安排任务") + + httpClient, err := createHTTPClient() + if err != nil { + return nil, err + } + + cfg := config.Get() + setTWFIDCookie(httpClient, cfg.BaseURL, "") + + if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { + return nil, err + } + + exams, err := parser.FetchAllExams(httpClient) + if err != nil { + return nil, apperr.New("fetch_exams", err, "获取考试安排失败") + } + + pbExams := convertExams(exams) + + result := &pb.ExamsResultData{ + StudentId: req.Username, + Semester: req.Semester, + Exams: pbExams, + } + + log.Info("考试安排获取成功", "exam_count", len(pbExams)) + return json.Marshal(result) +} + func convertCourses(courses []models.Course) []*pb.Course { result := make([]*pb.Course, len(courses)) for i, c := range courses { @@ -248,3 +334,59 @@ func convertToInt32(arr []int) []int32 { } return result } + +func convertGrades(grades []parser.GradeEntry) []*pb.Grade { + result := make([]*pb.Grade, 0, len(grades)) + for _, g := range grades { + var credit float32 + if v, err := parseFloat(g.Credit); err == nil { + credit = v + } + var gradePoint float32 + if v, err := parseFloat(g.GradePoint); err == nil { + gradePoint = v + } + result = append(result, &pb.Grade{ + CourseCode: g.Code, + CourseName: g.Name, + Credit: credit, + Grade: g.Score, + GradePoint: gradePoint, + }) + } + return result +} + +func convertExams(exams []parser.ExamEntry) []*pb.Exam { + result := make([]*pb.Exam, 0, len(exams)) + for _, e := range exams { + examTypeStr := "期末" + switch e.Type { + case parser.ExamTypeMidterm: + examTypeStr = "期中" + case parser.ExamTypeMakeup: + examTypeStr = "补考" + } + result = append(result, &pb.Exam{ + CourseName: e.Course, + ExamType: examTypeStr, + Date: e.Date, + Time: e.Time, + Room: e.Week, + Seat: e.Weekday, + }) + } + return result +} + +func parseFloat(s string) (float32, error) { + s = strings.TrimSpace(s) + if s == "" || s == "-" { + return 0, fmt.Errorf("empty") + } + v, err := strconv.ParseFloat(s, 32) + if err != nil { + return 0, err + } + return float32(v), nil +} diff --git a/parser/encoding.go b/parser/encoding.go new file mode 100644 index 0000000..5c47cf8 --- /dev/null +++ b/parser/encoding.go @@ -0,0 +1,74 @@ +package parser + +import ( + "bytes" + "io" + "net/http" + "strings" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +func decodeResponseBody(resp *http.Response) (string, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return decodeBody(body, resp.Header.Get("Content-Type")), nil +} + +func decodeBody(body []byte, contentType string) string { + lowerCT := strings.ToLower(contentType) + if strings.Contains(lowerCT, "charset=gb") || strings.Contains(lowerCT, "charset=gb2312") || strings.Contains(lowerCT, "charset=gbk") { + decoded, err := decodeGBK(body) + if err == nil { + return decoded + } + } + + if isValidUTF8(body) { + return string(body) + } + + decoded, err := decodeGBK(body) + if err == nil { + return decoded + } + return string(body) +} + +func decodeGBK(data []byte) (string, error) { + reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder()) + decoded, err := io.ReadAll(reader) + if err != nil { + return "", err + } + return string(decoded), nil +} + +func isValidUTF8(data []byte) bool { + for i := 0; i < len(data); i++ { + if data[i]&0x80 != 0 { + if data[i]&0xE0 == 0xC0 { + if i+1 >= len(data) || data[i+1]&0xC0 != 0x80 { + return false + } + i++ + } else if data[i]&0xF0 == 0xE0 { + if i+2 >= len(data) || data[i+1]&0xC0 != 0x80 || data[i+2]&0xC0 != 0x80 { + return false + } + i += 2 + } else if data[i]&0xF8 == 0xF0 { + if i+3 >= len(data) || data[i+1]&0xC0 != 0x80 || data[i+2]&0xC0 != 0x80 || data[i+3]&0xC0 != 0x80 { + return false + } + i += 3 + } else { + return false + } + } + } + return true +} \ No newline at end of file diff --git a/parser/exam.go b/parser/exam.go new file mode 100644 index 0000000..37bf36a --- /dev/null +++ b/parser/exam.go @@ -0,0 +1,128 @@ +package parser + +import ( + "fmt" + "net/http" + "strings" + + "schedule_converter/client" + "schedule_converter/config" + + "github.com/PuerkitoBio/goquery" +) + +type ExamType string + +const ( + ExamTypeFinal ExamType = "final" + ExamTypeMidterm ExamType = "midterm" + ExamTypeMakeup ExamType = "makeup" +) + +type ExamEntry struct { + Course string + Date string + Time string + Week string + Weekday string + Type ExamType +} + +func examTypeToCode(t ExamType) string { + switch t { + case ExamTypeMidterm: + return "02" + case ExamTypeMakeup: + return "03" + default: + return "01" + } +} + +func FetchExams(httpClient *http.Client, examType ExamType) ([]ExamEntry, error) { + examURL := config.BaseURL + "/kscx/queryKsForXs" + fmt.Printf("[考试] 正在获取考试时间数据 (%s)...\n", examType) + + data := fmt.Sprintf("xnxq=&kssjd=%s", examTypeToCode(examType)) + req, err := http.NewRequest("POST", examURL, 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() + + html, err := decodeResponseBody(resp) + if err != nil { + return nil, err + } + + exams, err := ParseExamTimeHTML(html, examType) + if err != nil { + return nil, err + } + + fmt.Printf("[考试] 共获取 %d 条考试记录 (%s)\n", len(exams), examType) + return exams, nil +} + +func FetchAllExams(httpClient *http.Client) ([]ExamEntry, error) { + var allExams []ExamEntry + for _, et := range []ExamType{ExamTypeFinal, ExamTypeMidterm, ExamTypeMakeup} { + exams, err := FetchExams(httpClient, et) + if err != nil { + fmt.Printf("[考试] 获取%s考试失败: %v\n", et, err) + continue + } + allExams = append(allExams, exams...) + } + return allExams, nil +} + +func ParseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var exams []ExamEntry + + doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { + rows := table.Find("tr") + if rows.Length() < 2 { + return + } + + rows.Each(func(idx int, row *goquery.Selection) { + if idx == 0 { + return + } + cells := row.Find("td") + if cells.Length() < 6 { + return + } + + texts := getCellTexts(cells) + + exam := ExamEntry{ + Course: safeGet(texts, 1), + Date: safeGet(texts, 2), + Time: safeGet(texts, 3), + Week: safeGet(texts, 4), + Weekday: safeGet(texts, 5), + Type: examType, + } + + if exam.Course != "" && len(exam.Course) >= 2 { + exams = append(exams, exam) + } + }) + }) + + return exams, nil +} \ No newline at end of file diff --git a/parser/score.go b/parser/score.go new file mode 100644 index 0000000..c242959 --- /dev/null +++ b/parser/score.go @@ -0,0 +1,257 @@ +package parser + +import ( + "fmt" + "net/http" + "strings" + + "schedule_converter/client" + "schedule_converter/config" + + "github.com/PuerkitoBio/goquery" +) + +type ScoreType string + +const ( + ScoreTypeFinal ScoreType = "final" + ScoreTypeMidterm ScoreType = "midterm" +) + +type GradeEntry struct { + Term string + Code string + Name string + Nature string + Category string + Credit string + Score string + GradePoint string + IsExam string +} + +type GPASummary struct { + AverageGPA string + Rank string + ExamTotalGPA string + ExamTotalCredits string + FailCredits string + Semesters string +} + +func FetchGrades(httpClient *http.Client,ExamType ScoreType) ([]GradeEntry, error) { + var gradeURL string + switch ExamType { + case ScoreTypeMidterm: + gradeURL = config.BaseURL + "/cjcx/queryQzcj" + default: + gradeURL = config.BaseURL + "/cjcx/queryQmcj" + } + + fmt.Printf("[成绩] 正在获取成绩数据 (%s)...\n", ExamType) + + req, err := http.NewRequest("GET", gradeURL, nil) + if err != nil { + return nil, err + } + req.Header = client.GetHeaders() + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + html, err := decodeResponseBody(resp) + if err != nil { + return nil, err + } + + grades, err := ParseGradesHTML(html) + if err != nil { + return nil, err + } + + fmt.Printf("[成绩] 共获取 %d 条成绩记录\n", len(grades)) + return grades, nil +} + +func FetchGPA(httpClient *http.Client) (*GPASummary, []GradeEntry, error) { + gpaURL := config.BaseURL + "/xfj/queryListXfj" + fmt.Println("[学分绩] 正在获取学分绩数据...") + + req, err := http.NewRequest("GET", gpaURL, nil) + if err != nil { + return nil, nil, err + } + req.Header = client.GetHeaders() + + resp, err := httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + html, err := decodeResponseBody(resp) + if err != nil { + return nil, nil, err + } + + summary, grades, err := ParseGPAHTML(html) + if err != nil { + return nil, nil, err + } + + fmt.Printf("[学分绩] 共获取 %d 门课程明细\n", len(grades)) + return summary, grades, nil +} + +func ParseGradesHTML(html string) ([]GradeEntry, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, err + } + + var grades []GradeEntry + + doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { + rows := table.Find("tr") + if rows.Length() < 2 { + return + } + + rows.Each(func(idx int, row *goquery.Selection) { + if idx == 0 { + return + } + cells := row.Find("td") + if cells.Length() < 8 { + return + } + + texts := getCellTexts(cells) + if len(texts) < 9 { + return + } + + isMidterm := len(texts) == 10 + + entry := GradeEntry{ + Term: safeGet(texts, 1), + Code: safeGet(texts, 3), + Name: safeGet(texts, 4), + Credit: safeGet(texts, 7), + } + + if isMidterm { + entry.Score = safeGet(texts, 8) + if len(texts) > 9 { + entry.GradePoint = safeGet(texts, 9) + } + } else { + if len(texts) >= 13 { + entry.Nature = safeGet(texts, 3) + entry.Category = safeGet(texts, 4) + entry.Score = safeGet(texts, 12) + entry.GradePoint = safeGet(texts, 9) + entry.IsExam = safeGet(texts, 6) + } else if len(texts) >= 10 { + entry.Score = safeGet(texts, 8) + entry.GradePoint = safeGet(texts, 9) + } + } + + if entry.Name != "" && len(entry.Name) >= 2 { + if entry.Score == "" { + entry.Score = "-" + } + grades = append(grades, entry) + } + }) + }) + + return grades, nil +} + +func ParseGPAHTML(html string) (*GPASummary, []GradeEntry, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, nil, err + } + + summary := &GPASummary{} + + doc.Find("span").Each(func(_ int, span *goquery.Selection) { + id, _ := span.Attr("id") + text := strings.TrimSpace(span.Text()) + switch id { + case "pjxfj": + summary.AverageGPA = text + case "zrs": + summary.Rank = text + case "kskxfj": + summary.ExamTotalGPA = text + case "kskxfs": + summary.ExamTotalCredits = text + case "kckxfj": + summary.FailCredits = text + case "xqs": + summary.Semesters = text + } + }) + + var grades []GradeEntry + + doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { + rows := table.Find("tr") + if rows.Length() < 2 { + return + } + + rows.Each(func(idx int, row *goquery.Selection) { + if idx == 0 { + return + } + cells := row.Find("td") + if cells.Length() < 10 { + return + } + + texts := getCellTexts(cells) + + entry := GradeEntry{ + Term: safeGet(texts, 0), + Code: safeGet(texts, 1), + Name: safeGet(texts, 2), + Nature: safeGet(texts, 3), + Category: safeGet(texts, 4), + IsExam: safeGet(texts, 6), + Credit: safeGet(texts, 7), + Score: safeGet(texts, 8), + GradePoint: safeGet(texts, 9), + } + + if entry.Name != "" && len(entry.Name) >= 2 { + grades = append(grades, entry) + } + }) + }) + + return summary, grades, nil +} + +func getCellTexts(cells *goquery.Selection) []string { + var texts []string + cells.Each(func(_ int, cell *goquery.Selection) { + text := strings.TrimSpace(cell.Text()) + texts = append(texts, text) + }) + return texts +} + +func safeGet(arr []string, idx int) string { + if idx < len(arr) { + return arr[idx] + } + return "" +} \ No newline at end of file