feat(grpc): add support for fetching grades and exams
All checks were successful
Build / build (push) Successful in 2m14s

Implement new gRPC task handlers for retrieving student grades and exam schedules. This includes adding new parser logic for processing score and exam data, and updating the client capabilities to advertise support for these new task types.

- Add `getGrades` and `getExams` handlers in `grpc/handler.go`
- Add `parser/encoding.go`, `parser/exam.go`, and `parser/score.go` for data parsing
- Update `grpc/client.go` to include `TASK_TYPE_GET_GRADES` and `TASK_TYPE_GET_EXAMS` in capabilities
This commit is contained in:
2026-05-11 00:46:38 +08:00
parent a04e5c2c50
commit 478ec3e3ab
5 changed files with 603 additions and 0 deletions

View File

@@ -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",
},
},

View File

@@ -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
}

74
parser/encoding.go Normal file
View File

@@ -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
}

128
parser/exam.go Normal file
View File

@@ -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
}

257
parser/score.go Normal file
View File

@@ -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 ""
}