Compare commits

..

7 Commits

Author SHA1 Message Date
WuYuuuub
04ad865bf2 cet成绩查询
All checks were successful
Build / build (push) Successful in 9m51s
2026-06-17 22:41:52 +08:00
WuYuuuub
8915fca841 cet成绩查询handler
Some checks failed
Build / build (push) Has been cancelled
2026-06-17 22:41:34 +08:00
WuYuuuub
4024f9eae0 删除了ai识别验证码部分,新增个人成绩补考标识和cet成绩查询
Some checks failed
Build / build (push) Failing after 2m13s
2026-06-17 14:48:27 +08:00
WuYuuuub
c4c3320f51 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	config/config.go
#	grpc/convert.go
#	grpc/handler.go
#	parser/score.go
#	service/service.go
2026-06-17 13:31:12 +08:00
WuYuuuub
58301bbed0 删除了ai识别验证码部分,新增个人成绩补考标识和cet成绩查询 2026-06-17 12:22:16 +08:00
lafay
ab37aeb2fc refactor: introduce service layer and migrate gRPC handlers to use it
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.
2026-06-16 18:33:13 +08:00
lafay
ae9297c11c refactor: 重构代码结构 2026-06-16 00:08:15 +08:00
36 changed files with 1404 additions and 802 deletions

115
.opencode/package-lock.json generated Normal file
View File

@@ -0,0 +1,115 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.4.3"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz",
"integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.4.3",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.97",
"@opentui/solid": ">=0.1.97"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz",
"integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -14,13 +15,13 @@ import (
var nameRegexp = regexp.MustCompile(`(\w+)同学`) var nameRegexp = regexp.MustCompile(`(\w+)同学`)
// LoginWithClient 使用指定的 HTTP 客户端和凭据登录 // LoginWithClient 使用指定的 HTTP 客户端和凭据登录
func LoginWithClient(httpClient *http.Client, captchaCode, username, password string) (bool, error) { func LoginWithClient(ctx context.Context, httpClient *http.Client, captchaCode, username, password string) (bool, error) {
log := slog.With("username", username) log := slog.With("username", username)
log.Debug("执行登录") log.Debug("执行登录")
data := fmt.Sprintf("usercode=%s&password=%s&code=%s", username, password, captchaCode) data := fmt.Sprintf("usercode=%s&password=%s&code=%s", username, password, captchaCode)
req, err := http.NewRequest("POST", config.Get().BaseURL+"/login", strings.NewReader(data)) req, err := http.NewRequestWithContext(ctx, "POST", config.Get().BaseURL+"/login", strings.NewReader(data))
if err != nil { if err != nil {
return false, err return false, err
} }

View File

@@ -1,55 +1,7 @@
package captcha package captcha
import ( // GetCaptchaCode 返回固定验证码占位值。
"fmt" // 教务系统验证码不再需要 AI 识别,登录时直接提交固定占位值 "...."。
"io" func GetCaptchaCode() string {
"log/slog" return "...."
"net/http"
"schedule_converter/client"
"schedule_converter/config"
)
func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
req, err := http.NewRequest("GET", config.Get().BaseURL+"/captchaImage", 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()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取验证码失败, 状态码: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
func GetAndRecognize(httpClient *http.Client) (string, error) {
_, err := SaveCaptchaImage(httpClient)
if err != nil {
return "", err
}
captchaCode, err := Recognize("captcha.png")
if err != nil {
return "", err
}
slog.Debug("验证码识别完成", "result", captchaCode)
return captchaCode, nil
}
func Recognize(imagePath string) (string, error) {
return "....", nil
} }

View File

@@ -1,8 +1,7 @@
package parser package client
import ( import (
"bytes" "bytes"
"encoding/json"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@@ -11,7 +10,8 @@ import (
"golang.org/x/text/transform" "golang.org/x/text/transform"
) )
func decodeResponseBody(resp *http.Response) (string, error) { // DecodeResponseBody 读取 HTTP 响应体并按 Content-Type 进行字符集解码(处理 GBK
func DecodeResponseBody(resp *http.Response) (string, error) {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", err return "", err
@@ -73,11 +73,3 @@ func isValidUTF8(data []byte) bool {
} }
return true return true
} }
func decodeJSONResponse(resp *http.Response, v any) error {
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return json.NewDecoder(bytes.NewReader(body)).Decode(v)
}

47
client/fetch.go Normal file
View File

@@ -0,0 +1,47 @@
package client
import (
"context"
"fmt"
"io"
"net/http"
"schedule_converter/config"
)
// FetchHTML 发送 HTTP 请求并返回解码后的 HTML 字符串(处理 GBK 字符集)。
// method 为 "GET" 或 "POST"path 拼接到 config.BaseURL 之后body 为请求体GET 传 nil
// referer 为可选的 Referer 路径(拼接到 BaseURL 之后),不需要时传 ""。
func FetchHTML(ctx context.Context, httpClient *http.Client, method, path string, body io.Reader, contentType, referer string) (string, error) {
resp, err := doRequest(ctx, httpClient, method, path, body, contentType, referer)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("请求 %s 失败, 状态码: %d", path, resp.StatusCode)
}
return DecodeResponseBody(resp)
}
// doRequest 构造并执行请求,设置 header可选 Referer透传 ctx。
func doRequest(ctx context.Context, httpClient *http.Client, method, path string, body io.Reader, contentType, referer string) (*http.Response, error) {
url := config.Get().BaseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header = GetHeadersWithContentType(contentType)
} else {
req.Header = GetHeaders()
}
if referer != "" {
req.Header.Set("Referer", config.Get().BaseURL+referer)
}
return httpClient.Do(req)
}

View File

@@ -1,27 +1,14 @@
package config package config
import ( import (
"log/slog"
"os" "os"
"strconv"
"sync" "sync"
"time"
) )
type Config struct { type Config struct {
BaseURL string BaseURL string
Username string Semester string
Password string TWFID string
Semester string
TWFID string
Recognizer RecognizerConfig
}
type RecognizerConfig struct {
APIURL string
APIKey string
Model string
Timeout time.Duration
} }
var ( var (
@@ -33,20 +20,8 @@ func Load() *Config {
cfgOnce.Do(func() { cfgOnce.Do(func() {
cfg = &Config{ cfg = &Config{
BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"), BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"),
Username: getEnv("JWTS_USERNAME", "2024212193"), Semester: getEnv("JWTS_SEMESTER", ""),
Password: getEnv("JWTS_PASSWORD", "wyb12580963"),
Semester: getEnv("JWTS_SEMESTER", "2025-20262"),
TWFID: getEnv("JWTS_TWFID", ""), TWFID: getEnv("JWTS_TWFID", ""),
Recognizer: RecognizerConfig{
APIURL: getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions"),
APIKey: getEnv("RECOGNIZER_API_KEY", "sk-6064466699a996b6f83e1ae8247c1cb4eeb4b72ce23cb001204335a2567d63c1"),
Model: getEnv("RECOGNIZER_MODEL", "qwen3.5-plus"),
Timeout: getEnvDuration("RECOGNIZER_TIMEOUT", 120*time.Second),
},
}
if cfg.Recognizer.APIKey == "" {
slog.Warn("RECOGNIZER_API_KEY not set, image recognition will not work")
} }
}) })
return cfg return cfg
@@ -67,35 +42,8 @@ func getEnv(key, defaultValue string) string {
return val return val
} }
func getEnvInt(key string, defaultValue int) int {
val := os.Getenv(key)
if val == "" {
return defaultValue
}
if i, err := strconv.Atoi(val); err == nil {
return i
}
return defaultValue
}
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
val := os.Getenv(key)
if val == "" {
return defaultValue
}
if d, err := time.ParseDuration(val); err == nil {
return d
}
return defaultValue
}
var ( var (
BaseURL = Get().BaseURL BaseURL = Get().BaseURL
Username = Get().Username Semester = Get().Semester
Password = Get().Password TWFID = Get().TWFID
Semester = Get().Semester
TWFID = Get().TWFID
RecognizerAPIURL = Get().Recognizer.APIURL
RecognizerAPIKey = Get().Recognizer.APIKey
RecognizerModel = Get().Recognizer.Model
) )

View File

@@ -78,18 +78,16 @@ func (c *Client) Connect(ctx context.Context) error {
// register 发送注册请求 // register 发送注册请求
func (c *Client) register() error { func (c *Client) register() error {
capabilities := make(map[string]string, len(supportedTaskTypes()))
for _, t := range supportedTaskTypes() {
capabilities[t.String()] = "true"
}
msg := &pb.StreamMessage{ msg := &pb.StreamMessage{
Message: &pb.StreamMessage_Register{ Message: &pb.StreamMessage_Register{
Register: &pb.RegisterRequest{ Register: &pb.RegisterRequest{
RunnerId: c.runnerID, RunnerId: c.runnerID,
Version: c.version, Version: c.version,
Capabilities: map[string]string{ Capabilities: capabilities,
"TASK_TYPE_GET_SCHEDULE": "true",
"TASK_TYPE_GET_GRADES": "true",
"TASK_TYPE_GET_EXAMS": "true",
"TASK_TYPE_LOGIN": "true",
"TASK_TYPE_GET_EMPTY_CLASSROOM": "true",
},
}, },
}, },
} }

View File

@@ -1,91 +0,0 @@
package grpc
import (
pb "schedule_converter/proto/runner"
"schedule_converter/models"
"schedule_converter/parser"
)
func convertCourses(courses []models.Course) []*pb.Course {
result := make([]*pb.Course, len(courses))
for i, c := range courses {
schedule := make([]*pb.ScheduleTime, len(c.Schedule))
for j, s := range c.Schedule {
schedule[j] = &pb.ScheduleTime{
Day: s.Day,
Sections: convertToInt32(s.Section),
Weeks: convertToInt32(s.Weeks),
}
}
result[i] = &pb.Course{
Name: c.Name,
Teacher: c.Teacher,
Room: c.Room,
Schedule: schedule,
}
}
return result
}
func convertToInt32(arr []int) []int32 {
result := make([]int32, len(arr))
for i, v := range arr {
result[i] = int32(v)
}
return result
}
func convertGrades(grades []parser.GradeEntry) []*pb.Grade {
result := make([]*pb.Grade, 0, len(grades))
for _, g := range grades {
result = append(result, &pb.Grade{
Term: g.Term,
CourseCode: g.Code,
CourseName: g.Name,
Nature: g.Nature,
Category: g.Category,
Credit: g.Credit,
Score: g.Score,
GradePoint: g.GradePoint,
IsExam: g.IsExam,
})
}
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,
Week: e.Week,
Weekday: e.Weekday,
})
}
return result
}
func convertEmptyClassrooms(entries []parser.EmptyClassroomEntry) []*pb.EmptyClassroomEntry {
result := make([]*pb.EmptyClassroomEntry, 0, len(entries))
for _, e := range entries {
result = append(result, &pb.EmptyClassroomEntry{
Classroom: e.Classroom,
Weekday: e.Weekday,
Periods: e.Periods,
Term: e.Term,
Weeks: e.Weeks,
})
}
return result
}

View File

@@ -2,21 +2,46 @@ package grpc
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"sync" "sync"
"time" "time"
"schedule_converter/service"
pb "schedule_converter/proto/runner" pb "schedule_converter/proto/runner"
) )
type TaskHandler struct { type TaskHandler struct {
cancelledTasks sync.Map cancelledTasks sync.Map
service *service.Service
} }
func NewTaskHandler() *TaskHandler { func NewTaskHandler() *TaskHandler {
return &TaskHandler{} return &TaskHandler{service: service.NewService()}
}
func supportedTaskTypes() []pb.TaskType {
return []pb.TaskType{
pb.TaskType_TASK_TYPE_LOGIN,
pb.TaskType_TASK_TYPE_GET_SCHEDULE,
pb.TaskType_TASK_TYPE_GET_GRADES,
pb.TaskType_TASK_TYPE_GET_EXAMS,
pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM,
pb.TaskType_TASK_TYPE_GET_CET,
}
}
func unmarshalPayload(payload []byte, v any) error {
if len(payload) == 0 {
return fmt.Errorf("任务 payload 为空")
}
if err := json.Unmarshal(payload, v); err != nil {
return fmt.Errorf("解析任务 payload 失败: %w", err)
}
return nil
} }
func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult { func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult {
@@ -71,6 +96,8 @@ func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, erro
return h.login(ctx, task.Payload) return h.login(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM: case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM:
return h.getEmptyClassrooms(ctx, task.Payload) return h.getEmptyClassrooms(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_GET_CET:
return h.getCET(ctx, task.Payload)
default: default:
return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type) return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type)
} }

29
grpc/handler_cet.go Normal file
View File

@@ -0,0 +1,29 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
apperr "schedule_converter/pkg/errors"
)
func (h *TaskHandler) getCET(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetCetPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, err
}
log := slog.With("username", req.Username)
log.Info("获取四六级成绩任务")
result, err := h.service.GetCET(ctx, &req)
if err != nil {
return nil, apperr.New("get_cet", err, "获取四六级成绩失败")
}
log.Info("四六级成绩获取成功", "score_count", len(result.Scores))
return json.Marshal(result)
}

View File

@@ -7,8 +7,6 @@ import (
pb "schedule_converter/proto/runner" pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors" apperr "schedule_converter/pkg/errors"
) )
@@ -21,31 +19,11 @@ func (h *TaskHandler) getEmptyClassrooms(ctx context.Context, payload []byte) ([
log := slog.With("username", req.Username, "semester", req.Semester) log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取空教室任务") log.Info("获取空教室任务")
httpClient, err := createHTTPClient() result, err := h.service.GetEmptyClassrooms(ctx, &req)
if err != nil { if err != nil {
return nil, err return nil, apperr.New("get_empty_classrooms", err, "获取空教室失败")
} }
cfg := config.Get() log.Info("空教室获取成功", "classroom_count", len(result.Classrooms))
setTWFIDCookie(httpClient, cfg.BaseURL, "")
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
classrooms, err := parser.FetchEmptyClassrooms(httpClient, req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode)
if err != nil {
return nil, apperr.New("fetch_empty_classrooms", err, "获取空教室失败")
}
pbClassrooms := convertEmptyClassrooms(classrooms)
result := &pb.EmptyClassroomResultData{
StudentId: req.Username,
Semester: req.Semester,
Classrooms: pbClassrooms,
}
log.Info("空教室获取成功", "classroom_count", len(pbClassrooms))
return json.Marshal(result) return json.Marshal(result)
} }

View File

@@ -7,8 +7,6 @@ import (
pb "schedule_converter/proto/runner" pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors" apperr "schedule_converter/pkg/errors"
) )
@@ -21,31 +19,11 @@ func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, err
log := slog.With("username", req.Username, "semester", req.Semester) log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取考试安排任务") log.Info("获取考试安排任务")
httpClient, err := createHTTPClient() result, err := h.service.GetExams(ctx, &req)
if err != nil { if err != nil {
return nil, err return nil, apperr.New("get_exams", err, "获取考试安排失败")
} }
cfg := config.Get() log.Info("考试安排获取成功", "exam_count", len(result.Exams))
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) return json.Marshal(result)
} }

View File

@@ -7,8 +7,6 @@ import (
pb "schedule_converter/proto/runner" pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors" apperr "schedule_converter/pkg/errors"
) )
@@ -21,46 +19,11 @@ func (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, er
log := slog.With("username", req.Username, "semester", req.Semester) log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取成绩任务") log.Info("获取成绩任务")
httpClient, err := createHTTPClient() result, err := h.service.GetGrades(ctx, &req)
if err != nil { if err != nil {
return nil, err return nil, apperr.New("get_grades", err, "获取成绩失败")
} }
cfg := config.Get() log.Info("成绩获取成功", "grade_count", len(result.Grades))
setTWFIDCookie(httpClient, cfg.BaseURL, "")
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
var gpaSummary *pb.GpaSummary
summary, 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, "获取成绩失败")
}
} else if summary != nil {
gpaSummary = &pb.GpaSummary{
AverageGpa: summary.AverageGPA,
Rank: summary.Rank,
ExamTotalGpa: summary.ExamTotalGPA,
ExamTotalCredits: summary.ExamTotalCredits,
FailCredits: summary.FailCredits,
Semesters: summary.Semesters,
}
}
pbGrades := convertGrades(grades)
result := &pb.GradesResultData{
StudentId: req.Username,
Semester: req.Semester,
Grades: pbGrades,
GpaSummary: gpaSummary,
}
log.Info("成绩获取成功", "grade_count", len(pbGrades))
return json.Marshal(result) return json.Marshal(result)
} }

View File

@@ -7,7 +7,7 @@ import (
pb "schedule_converter/proto/runner" pb "schedule_converter/proto/runner"
"schedule_converter/config" apperr "schedule_converter/pkg/errors"
) )
func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) { func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) {
@@ -19,16 +19,8 @@ func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error)
log := slog.With("username", req.Username) log := slog.With("username", req.Username)
log.Info("登录任务") log.Info("登录任务")
httpClient, err := createHTTPClient() if _, err := h.service.Login(ctx, req.Username, req.Password, req.Twfid); err != nil {
if err != nil { return nil, apperr.New("login", err, "登录失败")
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, req.Twfid)
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
} }
log.Info("登录成功") log.Info("登录成功")

View File

@@ -7,7 +7,7 @@ import (
pb "schedule_converter/proto/runner" pb "schedule_converter/proto/runner"
"schedule_converter/config" apperr "schedule_converter/pkg/errors"
) )
func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) { func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) {
@@ -19,25 +19,11 @@ func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte,
log := slog.With("username", req.Username, "semester", req.Semester) log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取课表任务") log.Info("获取课表任务")
httpClient, err := createHTTPClient() result, err := h.service.GetSchedule(ctx, &req)
if err != nil { if err != nil {
return nil, err return nil, apperr.New("get_schedule", err, "获取课表失败")
} }
cfg := config.Get() log.Info("课表获取成功", "course_count", len(result.Courses))
setTWFIDCookie(httpClient, cfg.BaseURL, "")
courses, err := executeLoginAndFetchSchedule(ctx, httpClient, req.Username, req.Password, req.Semester)
if err != nil {
return nil, err
}
result := &pb.ScheduleResultData{
StudentId: req.Username,
Semester: req.Semester,
Courses: convertCourses(courses),
}
log.Info("课表获取成功", "course_count", len(courses))
return json.Marshal(result) return json.Marshal(result)
} }

10
models/empty_classroom.go Normal file
View File

@@ -0,0 +1,10 @@
package models
// EmptyClassroomEntry 空教室条目
type EmptyClassroomEntry struct {
Classroom string `json:"classroom"`
Weekday string `json:"weekday"`
Periods string `json:"periods"`
Term string `json:"term"`
Weeks string `json:"weeks"`
}

20
models/exam.go Normal file
View File

@@ -0,0 +1,20 @@
package models
// ExamType 考试类型
type ExamType string
const (
ExamTypeFinal ExamType = "final"
ExamTypeMidterm ExamType = "midterm"
ExamTypeMakeup ExamType = "makeup"
)
// ExamEntry 考试条目
type ExamEntry struct {
Course string
Date string
Time string
Week string
Weekday string
Type ExamType
}

33
models/grade.go Normal file
View File

@@ -0,0 +1,33 @@
package models
// ScoreType 成绩类型
type ScoreType string
const (
ScoreTypeFinal ScoreType = "final"
ScoreTypeMidterm ScoreType = "midterm"
)
// GradeEntry 成绩条目
type GradeEntry struct {
Term string
Code string
Name string
Nature string
Category string
Credit string
Score string
GradePoint string
IsExam string
IsRetake bool
}
// GPASummary 学分绩概要
type GPASummary struct {
AverageGPA string
Rank string
ExamTotalGPA string
ExamTotalCredits string
FailCredits string
Semesters string
}

184
parser/cet.go Normal file
View File

@@ -0,0 +1,184 @@
package parser
import (
"fmt"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
)
// CETScore 四六级成绩条目。
type CETScore struct {
Level string // 等级:英语四级/英语六级
ExamTime string // 考试时间:如 2025年06月
TicketNum string // 准考证号
Score string // 成绩
}
// CETStudentInfo 四六级查询中的学生信息。
type CETStudentInfo struct {
StudentID string // 学号
Name string // 姓名
Gender string // 性别
IDCard string // 身份证号
Duration string // 学制
Grade string // 年级
College string // 学院
Major string // 专业
}
// CETResult 四六级查询结果。
type CETResult struct {
StudentInfo CETStudentInfo
Scores []CETScore
}
// cetScoreRegex 匹配四六级成绩行。
// 示例:英语六级 时间 2025年06月 准考证号 372220251213105 成绩 445
var cetScoreRegex = regexp.MustCompile(`(英语[四六]级)\s+时间\s*([0-9]{4}年[0-9]{1,2}月)\s+准考证号\s*([0-9A-Za-z]+)\s+成绩\s*([0-9]+|缺考|合格|不合格|优秀|良好|通过|未通过)`)
// FetchCETHTML 解析四六级成绩 HTML 页面。
func ParseCETHTML(html string) (*CETResult, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, err
}
result := &CETResult{}
table := findCETTable(doc)
if table.Length() == 0 {
return nil, fmt.Errorf("未找到四六级成绩表格")
}
parseCETStudentInfo(table, &result.StudentInfo)
result.Scores = parseCETScores(table)
if len(result.Scores) == 0 {
result.Scores = parseCETScoresFromText(doc.Find(".jdcx_area").Text())
}
if len(result.Scores) == 0 {
result.Scores = parseCETScoresFromText(doc.Find(".Contentbox").Text())
}
if len(result.Scores) == 0 {
result.Scores = parseCETScoresFromText(doc.Text())
}
return result, nil
}
func findCETTable(doc *goquery.Document) *goquery.Selection {
selectors := []string{
"table.addlist",
".jdcx_area table",
"form#ff table",
"table:contains('已有成绩')",
}
for _, selector := range selectors {
table := doc.Find(selector).First()
if table.Length() > 0 {
return table
}
}
return doc.Find("__not_found__")
}
func parseCETStudentInfo(table *goquery.Selection, info *CETStudentInfo) {
table.Find("tr").Each(func(_ int, row *goquery.Selection) {
var label string
row.Find("th, td").Each(func(_ int, cell *goquery.Selection) {
text := normalizeCETText(cell.Text())
if text == "" {
return
}
if cell.Is("th") {
label = normalizeCETLabel(text)
return
}
assignCETStudentField(info, label, text)
label = ""
})
})
}
func assignCETStudentField(info *CETStudentInfo, label, value string) {
switch label {
case "学号":
info.StudentID = value
case "姓名":
info.Name = value
case "性别":
info.Gender = value
case "身份证号":
info.IDCard = value
case "学制":
info.Duration = value
case "年级":
info.Grade = value
case "学院":
info.College = value
case "专业":
info.Major = value
}
}
func parseCETScores(table *goquery.Selection) []CETScore {
var scores []CETScore
table.Find("tr").Each(func(_ int, row *goquery.Selection) {
rowText := normalizeCETText(row.Text())
if !strings.Contains(rowText, "英语四级") && !strings.Contains(rowText, "英语六级") {
return
}
scores = append(scores, parseCETScoresFromText(rowText)...)
})
return deduplicateCETScores(scores)
}
func parseCETScoresFromText(text string) []CETScore {
text = normalizeCETText(text)
matches := cetScoreRegex.FindAllStringSubmatch(text, -1)
scores := make([]CETScore, 0, len(matches))
for _, match := range matches {
if len(match) < 5 {
continue
}
scores = append(scores, CETScore{
Level: strings.TrimSpace(match[1]),
ExamTime: strings.TrimSpace(match[2]),
TicketNum: strings.TrimSpace(match[3]),
Score: strings.TrimSpace(match[4]),
})
}
return deduplicateCETScores(scores)
}
func deduplicateCETScores(scores []CETScore) []CETScore {
if len(scores) <= 1 {
return scores
}
seen := make(map[string]struct{}, len(scores))
result := make([]CETScore, 0, len(scores))
for _, score := range scores {
key := strings.Join([]string{score.Level, score.ExamTime, score.TicketNum, score.Score}, "|")
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, score)
}
return result
}
func normalizeCETLabel(label string) string {
label = normalizeCETText(label)
label = strings.ReplaceAll(label, " ", "")
label = strings.ReplaceAll(label, "", "")
label = strings.ReplaceAll(label, ":", "")
return label
}
func normalizeCETText(text string) string {
text = strings.ReplaceAll(text, "\u00a0", " ")
return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
}

View File

@@ -2,82 +2,15 @@ package parser
import ( import (
"fmt" "fmt"
"log/slog"
"net/http"
"strings" "strings"
"schedule_converter/client" "schedule_converter/models"
"schedule_converter/config"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
type EmptyClassroomEntry struct { // ParseEmptyClassroomHTML 解析空教室查询结果 HTML返回空教室条目列表。
Classroom string `json:"classroom"` func ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) {
Weekday string `json:"weekday"`
Periods string `json:"periods"`
Term string `json:"term"`
Weeks string `json:"weeks"`
}
type buildingInfo struct {
DM string `json:"dm"`
MC string `json:"mc"`
}
type venueInfo struct {
DM string `json:"dm"`
MC string `json:"mc"`
}
func FetchEmptyClassrooms(httpClient *http.Client, semester, weekStart, weekEnd, campusCode string) ([]EmptyClassroomEntry, error) {
url := config.Get().BaseURL + "/kjscx/queryKjs"
log := slog.With("semester", semester, "week_start", weekStart, "week_end", weekEnd)
log.Info("获取空教室数据")
// First GET to establish session
resp, err := httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("访问空教室页面失败: %w", err)
}
resp.Body.Close()
// POST with query parameters
data := fmt.Sprintf("pageXnxq=%s&pageZc1=%s&pageZc2=%s&pageXiaoqu=%s",
semester, weekStart, weekEnd, campusCode)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
return nil, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
req.Header.Set("Referer", url)
resp, err = httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("查询空教室失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("查询空教室失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, err
}
entries, err := parseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
if err != nil {
return nil, err
}
log.Info("空教室数据获取完成", "count", len(entries))
return entries, nil
}
func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) {
if html == "" { if html == "" {
return nil, nil return nil, nil
} }
@@ -94,7 +27,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty
dayNames := [7]string{"星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"} dayNames := [7]string{"星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"}
periodLabels := [6]string{"1,2", "3,4", "5,6", "7,8", "9,10", "11,12"} periodLabels := [6]string{"1,2", "3,4", "5,6", "7,8", "9,10", "11,12"}
var classrooms []EmptyClassroomEntry var classrooms []models.EmptyClassroomEntry
// Find the main data table with class="dataTable" // Find the main data table with class="dataTable"
doc.Find("table.dataTable").Each(func(_ int, table *goquery.Selection) { doc.Find("table.dataTable").Each(func(_ int, table *goquery.Selection) {
@@ -201,7 +134,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty
if weekStart != "" && weekEnd != "" { if weekStart != "" && weekEnd != "" {
weekStr = fmt.Sprintf("%s-%s周", weekStart, weekEnd) weekStr = fmt.Sprintf("%s-%s周", weekStart, weekEnd)
} }
entry := EmptyClassroomEntry{ entry := models.EmptyClassroomEntry{
Classroom: classroomName, Classroom: classroomName,
Weekday: strings.Join(weekdays, ", "), Weekday: strings.Join(weekdays, ", "),
Periods: strings.Join(slots, "\n"), Periods: strings.Join(slots, "\n"),
@@ -216,69 +149,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty
return classrooms, nil return classrooms, nil
} }
func fetchBuildings(httpClient *http.Client, campusCode string) ([]buildingInfo, error) {
url := config.Get().BaseURL + "/kjscx/queryJxlListBySjid"
slog.Info("获取楼号列表", "campus", campusCode)
data := fmt.Sprintf("id=%s", campusCode)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
return nil, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取楼号列表失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取楼号列表失败, 状态码: %d", resp.StatusCode)
}
var buildings []buildingInfo
if err := decodeJSONResponse(resp, &buildings); err != nil {
return nil, fmt.Errorf("解析楼号列表失败: %w", err)
}
return buildings, nil
}
func fetchVenues(httpClient *http.Client, buildingCode string) ([]venueInfo, error) {
url := config.Get().BaseURL + "/kjscx/queryJxcdListBySjid"
slog.Info("获取场地列表", "building", buildingCode)
data := fmt.Sprintf("id=%s", buildingCode)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
return nil, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取场地列表失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取场地列表失败, 状态码: %d", resp.StatusCode)
}
var venues []venueInfo
if err := decodeJSONResponse(resp, &venues); err != nil {
return nil, fmt.Errorf("解析场地列表失败: %w", err)
}
return venues, nil
}
// TestParseEmptyClassroomHTML is a test helper function that exports the parser for testing // TestParseEmptyClassroomHTML is a test helper function that exports the parser for testing
func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) { func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) {
return parseEmptyClassroomHTML(html, semester, weekStart, weekEnd) return ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
} }

View File

@@ -1,107 +1,38 @@
package parser package parser
import ( import (
"fmt"
"log/slog"
"net/http"
"strings" "strings"
"schedule_converter/client" "schedule_converter/models"
"schedule_converter/config"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
type ExamType string // examTypeToCode 将考试类型映射为教务系统的请求码。
func examTypeToCode(t models.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 { switch t {
case ExamTypeMidterm: case models.ExamTypeMidterm:
return "02" return "02"
case ExamTypeMakeup: case models.ExamTypeMakeup:
return "03" return "03"
default: default:
return "01" return "01"
} }
} }
func FetchExams(httpClient *http.Client, examType ExamType) ([]ExamEntry, error) { // ExamTypeCode 返回考试类型对应的请求码(供 client 层构造请求使用)。
examURL := config.Get().BaseURL + "/kscx/queryKsForXs" func ExamTypeCode(t models.ExamType) string {
log := slog.With("exam_type", string(examType)) return examTypeToCode(t)
log.Info("获取考试时间数据")
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()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取考试数据失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, err
}
exams, err := parseExamTimeHTML(html, examType)
if err != nil {
return nil, err
}
log.Info("考试数据获取完成", "exam_count", len(exams))
return exams, nil
} }
func FetchAllExams(httpClient *http.Client) ([]ExamEntry, error) { // ParseExamHTML 解析考试安排页面 HTML返回考试条目列表。
var allExams []ExamEntry func ParseExamHTML(html string, examType models.ExamType) ([]models.ExamEntry, error) {
var lastErr error
for _, et := range []ExamType{ExamTypeFinal, ExamTypeMidterm, ExamTypeMakeup} {
exams, err := FetchExams(httpClient, et)
if err != nil {
slog.Warn("获取考试数据失败", "exam_type", string(et), "error", err)
lastErr = err
continue
}
allExams = append(allExams, exams...)
}
if len(allExams) == 0 && lastErr != nil {
return nil, fmt.Errorf("所有考试类型获取均失败: %w", lastErr)
}
return allExams, nil
}
func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil { if err != nil {
return nil, err return nil, err
} }
var exams []ExamEntry var exams []models.ExamEntry
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
rows := table.Find("tr") rows := table.Find("tr")
@@ -120,7 +51,7 @@ func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) {
texts := getCellTexts(cells) texts := getCellTexts(cells)
exam := ExamEntry{ exam := models.ExamEntry{
Course: safeGet(texts, 1), Course: safeGet(texts, 1),
Date: safeGet(texts, 2), Date: safeGet(texts, 2),
Time: safeGet(texts, 3), Time: safeGet(texts, 3),

View File

@@ -1,16 +1,11 @@
package parser package parser
import ( import (
"fmt"
"io"
"net/http"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models" "schedule_converter/models"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
@@ -23,32 +18,6 @@ type weekRoomEntry struct {
room 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 // 解析课表HTML
func ParseScheduleHTML(html string) ([]models.Course, error) { func ParseScheduleHTML(html string) ([]models.Course, error) {
// 创建一个带Reader的Document // 创建一个带Reader的Document

View File

@@ -1,128 +1,21 @@
package parser package parser
import ( import (
"fmt"
"log/slog"
"net/http"
"strings" "strings"
"schedule_converter/client" "schedule_converter/models"
"schedule_converter/config"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
type ScoreType string // ParseGradesHTML 解析成绩页面 HTML返回成绩条目列表。
func ParseGradesHTML(html string) ([]models.GradeEntry, error) {
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.Get().BaseURL + "/cjcx/queryQzcj"
default:
gradeURL = config.Get().BaseURL + "/cjcx/queryQmcj"
}
log := slog.With("exam_type", string(examType))
log.Info("获取成绩数据")
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()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取成绩失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, err
}
grades, err := parseGradesHTML(html)
if err != nil {
return nil, err
}
log.Info("成绩获取完成", "grade_count", len(grades))
return grades, nil
}
func FetchGPA(httpClient *http.Client) (*GPASummary, []GradeEntry, error) {
gpaURL := config.Get().BaseURL + "/xfj/queryListXfj"
slog.Info("获取学分绩数据")
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()
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("获取学分绩失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, nil, err
}
summary, grades, err := parseGPAHTML(html)
if err != nil {
return nil, nil, err
}
slog.Info("学分绩获取完成", "grade_count", len(grades))
return summary, grades, nil
}
func parseGradesHTML(html string) ([]GradeEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil { if err != nil {
return nil, err return nil, err
} }
var grades []GradeEntry var grades []models.GradeEntry
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
rows := table.Find("tr") rows := table.Find("tr")
@@ -146,29 +39,13 @@ func parseGradesHTML(html string) ([]GradeEntry, error) {
isMidterm := len(texts) == 10 isMidterm := len(texts) == 10
entry := GradeEntry{ entry := parseGradeRowTexts(texts, detectRetake(row))
Term: safeGet(texts, 1),
Code: safeGet(texts, 3),
Name: safeGet(texts, 4),
Credit: safeGet(texts, 7),
}
if isMidterm { if isMidterm && entry.Score == "" {
entry.Score = safeGet(texts, 8) entry.Score = safeGet(texts, 8)
if len(texts) > 9 { if len(texts) > 9 {
entry.GradePoint = safeGet(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.Name != "" && len(entry.Name) >= 2 {
@@ -183,13 +60,14 @@ func parseGradesHTML(html string) ([]GradeEntry, error) {
return grades, nil return grades, nil
} }
func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) { // ParseGPAHTML 解析学分绩页面 HTML返回 GPA 概要与成绩条目列表。
func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
summary := &GPASummary{} summary := &models.GPASummary{}
doc.Find("span").Each(func(_ int, span *goquery.Selection) { doc.Find("span").Each(func(_ int, span *goquery.Selection) {
id, _ := span.Attr("id") id, _ := span.Attr("id")
@@ -210,7 +88,7 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) {
} }
}) })
var grades []GradeEntry var grades []models.GradeEntry
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
rows := table.Find("tr") rows := table.Find("tr")
@@ -229,17 +107,7 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) {
texts := getCellTexts(cells) texts := getCellTexts(cells)
entry := GradeEntry{ entry := parseGradeRowTexts(texts, detectRetake(row))
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 { if entry.Name != "" && len(entry.Name) >= 2 {
grades = append(grades, entry) grades = append(grades, entry)
@@ -250,6 +118,74 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) {
return summary, grades, nil return summary, grades, nil
} }
func parseGradeRowTexts(texts []string, isRetake bool) models.GradeEntry {
entry := models.GradeEntry{IsRetake: isRetake}
// 详细成绩/GPA 页面格式:
// 序号、学期、学院、课程代码、课程名称、课程性质、课程类别、学分、是否考试课、是否学位课、成绩标记、成绩、最终成绩、绩点1、绩点、录入时间、备注。
if len(texts) >= 15 && looksLikeDetailedGradeRow(texts) {
entry.Term = safeGet(texts, 1)
entry.Code = safeGet(texts, 3)
entry.Name = safeGet(texts, 4)
entry.Nature = safeGet(texts, 5)
entry.Category = safeGet(texts, 6)
entry.Credit = safeGet(texts, 7)
entry.IsExam = safeGet(texts, 8)
entry.Score = firstNonEmpty(safeGet(texts, 12), safeGet(texts, 11))
entry.GradePoint = firstNonEmpty(safeGet(texts, 14), safeGet(texts, 13))
return entry
}
// 常规成绩页面格式:学年、学期、课程代码、课程名称、课程性质、课程类别、学分、成绩、绩点、...
if len(texts) >= 10 {
entry.Term = safeGet(texts, 1)
entry.Code = safeGet(texts, 2)
entry.Name = safeGet(texts, 3)
entry.Nature = safeGet(texts, 4)
entry.Category = safeGet(texts, 5)
entry.Credit = safeGet(texts, 6)
entry.Score = safeGet(texts, 7)
entry.GradePoint = safeGet(texts, 8)
return entry
}
entry.Term = safeGet(texts, 1)
entry.Code = safeGet(texts, 3)
entry.Name = safeGet(texts, 4)
entry.Credit = safeGet(texts, 7)
entry.Score = safeGet(texts, 8)
if len(texts) > 9 {
entry.GradePoint = safeGet(texts, 9)
}
return entry
}
func looksLikeDetailedGradeRow(texts []string) bool {
return safeGet(texts, 3) != "" && safeGet(texts, 4) != "" && safeGet(texts, 7) != "" && (safeGet(texts, 8) == "是" || safeGet(texts, 8) == "否")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
// detectRetake 检测成绩行是否包含补考标记。
// 教务系统在成绩单元格内以 <font color="#0066ff">补考</font> 标记补考,
// 也会在整行文本中出现"补考"字样。
func detectRetake(row *goquery.Selection) bool {
// 方式1查找包含"补考"文字的 <font> 标签
if row.Find("font:contains('补考')").Length() > 0 {
return true
}
// 方式2整行纯文本包含"补考"
rowText := strings.TrimSpace(row.Text())
return strings.Contains(rowText, "补考")
}
func getCellTexts(cells *goquery.Selection) []string { func getCellTexts(cells *goquery.Selection) []string {
var texts []string var texts []string
cells.Each(func(_ int, cell *goquery.Selection) { cells.Each(func(_ int, cell *goquery.Selection) {

View File

@@ -7,25 +7,26 @@ import (
var ErrLoginFailed = errors.New("login failed") var ErrLoginFailed = errors.New("login failed")
type ScheduleError struct { // AppError 携带操作名Op与中文提示Msg的错误类型用于统一日志与错误上报。
type AppError struct {
Op string Op string
Err error Err error
Msg string Msg string
} }
func (e *ScheduleError) Error() string { func (e *AppError) Error() string {
if e.Msg != "" { if e.Msg != "" {
return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err)
} }
return fmt.Sprintf("%s: %v", e.Op, e.Err) return fmt.Sprintf("%s: %v", e.Op, e.Err)
} }
func (e *ScheduleError) Unwrap() error { func (e *AppError) Unwrap() error {
return e.Err return e.Err
} }
func New(op string, err error, msg string) error { func New(op string, err error, msg string) error {
return &ScheduleError{ return &AppError{
Op: op, Op: op,
Err: err, Err: err,
Msg: msg, Msg: msg,

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v7.34.1 // protoc v6.32.0
// source: proto/runner/runner.proto // source: proto/runner/runner.proto
package runner package runner
@@ -32,6 +32,7 @@ const (
TaskType_TASK_TYPE_GET_EXAMS TaskType = 4 // 获取考试安排 TaskType_TASK_TYPE_GET_EXAMS TaskType = 4 // 获取考试安排
TaskType_TASK_TYPE_GET_USER_INFO TaskType = 5 // 获取用户信息 TaskType_TASK_TYPE_GET_USER_INFO TaskType = 5 // 获取用户信息
TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM TaskType = 6 // 获取空教室 TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM TaskType = 6 // 获取空教室
TaskType_TASK_TYPE_GET_CET TaskType = 7 // 获取四六级成绩
) )
// Enum value maps for TaskType. // Enum value maps for TaskType.
@@ -44,6 +45,7 @@ var (
4: "TASK_TYPE_GET_EXAMS", 4: "TASK_TYPE_GET_EXAMS",
5: "TASK_TYPE_GET_USER_INFO", 5: "TASK_TYPE_GET_USER_INFO",
6: "TASK_TYPE_GET_EMPTY_CLASSROOM", 6: "TASK_TYPE_GET_EMPTY_CLASSROOM",
7: "TASK_TYPE_GET_CET",
} }
TaskType_value = map[string]int32{ TaskType_value = map[string]int32{
"TASK_TYPE_UNKNOWN": 0, "TASK_TYPE_UNKNOWN": 0,
@@ -53,6 +55,7 @@ var (
"TASK_TYPE_GET_EXAMS": 4, "TASK_TYPE_GET_EXAMS": 4,
"TASK_TYPE_GET_USER_INFO": 5, "TASK_TYPE_GET_USER_INFO": 5,
"TASK_TYPE_GET_EMPTY_CLASSROOM": 6, "TASK_TYPE_GET_EMPTY_CLASSROOM": 6,
"TASK_TYPE_GET_CET": 7,
} }
) )
@@ -1378,6 +1381,7 @@ type Grade struct {
Score string `protobuf:"bytes,7,opt,name=score,proto3" json:"score,omitempty"` // 成绩 (可能是等级或分数) Score string `protobuf:"bytes,7,opt,name=score,proto3" json:"score,omitempty"` // 成绩 (可能是等级或分数)
GradePoint string `protobuf:"bytes,8,opt,name=grade_point,json=gradePoint,proto3" json:"grade_point,omitempty"` // 绩点 GradePoint string `protobuf:"bytes,8,opt,name=grade_point,json=gradePoint,proto3" json:"grade_point,omitempty"` // 绩点
IsExam string `protobuf:"bytes,9,opt,name=is_exam,json=isExam,proto3" json:"is_exam,omitempty"` // 是否考试课 IsExam string `protobuf:"bytes,9,opt,name=is_exam,json=isExam,proto3" json:"is_exam,omitempty"` // 是否考试课
IsRetake bool `protobuf:"varint,10,opt,name=is_retake,json=isRetake,proto3" json:"is_retake,omitempty"` // 是否为补考
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -1475,6 +1479,13 @@ func (x *Grade) GetIsExam() string {
return "" return ""
} }
func (x *Grade) GetIsRetake() bool {
if x != nil {
return x.IsRetake
}
return false
}
// ExamsResultData 考试安排结果数据 // ExamsResultData 考试安排结果数据
type ExamsResultData struct { type ExamsResultData struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -1945,6 +1956,282 @@ func (x *EmptyClassroomEntry) GetWeeks() string {
return "" return ""
} }
// GetCetPayload 获取四六级成绩任务载荷
type GetCetPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` // 学号
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` // 密码
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetCetPayload) Reset() {
*x = GetCetPayload{}
mi := &file_proto_runner_runner_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetCetPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetCetPayload) ProtoMessage() {}
func (x *GetCetPayload) ProtoReflect() protoreflect.Message {
mi := &file_proto_runner_runner_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetCetPayload.ProtoReflect.Descriptor instead.
func (*GetCetPayload) Descriptor() ([]byte, []int) {
return file_proto_runner_runner_proto_rawDescGZIP(), []int{24}
}
func (x *GetCetPayload) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *GetCetPayload) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
// CetScore 四六级成绩条目
type CetScore struct {
state protoimpl.MessageState `protogen:"open.v1"`
Level string `protobuf:"bytes,1,opt,name=level,proto3" json:"level,omitempty"` // 等级(英语四级/英语六级)
ExamTime string `protobuf:"bytes,2,opt,name=exam_time,json=examTime,proto3" json:"exam_time,omitempty"` // 考试时间,如 2025年06月
TicketNum string `protobuf:"bytes,3,opt,name=ticket_num,json=ticketNum,proto3" json:"ticket_num,omitempty"` // 准考证号
Score string `protobuf:"bytes,4,opt,name=score,proto3" json:"score,omitempty"` // 成绩
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CetScore) Reset() {
*x = CetScore{}
mi := &file_proto_runner_runner_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CetScore) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CetScore) ProtoMessage() {}
func (x *CetScore) ProtoReflect() protoreflect.Message {
mi := &file_proto_runner_runner_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CetScore.ProtoReflect.Descriptor instead.
func (*CetScore) Descriptor() ([]byte, []int) {
return file_proto_runner_runner_proto_rawDescGZIP(), []int{25}
}
func (x *CetScore) GetLevel() string {
if x != nil {
return x.Level
}
return ""
}
func (x *CetScore) GetExamTime() string {
if x != nil {
return x.ExamTime
}
return ""
}
func (x *CetScore) GetTicketNum() string {
if x != nil {
return x.TicketNum
}
return ""
}
func (x *CetScore) GetScore() string {
if x != nil {
return x.Score
}
return ""
}
// CetStudentInfo 四六级查询页面的学生信息
type CetStudentInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
StudentId string `protobuf:"bytes,1,opt,name=student_id,json=studentId,proto3" json:"student_id,omitempty"` // 学号
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // 姓名
Gender string `protobuf:"bytes,3,opt,name=gender,proto3" json:"gender,omitempty"` // 性别
IdCard string `protobuf:"bytes,4,opt,name=id_card,json=idCard,proto3" json:"id_card,omitempty"` // 身份证号
Duration string `protobuf:"bytes,5,opt,name=duration,proto3" json:"duration,omitempty"` // 学制
Grade string `protobuf:"bytes,6,opt,name=grade,proto3" json:"grade,omitempty"` // 年级
College string `protobuf:"bytes,7,opt,name=college,proto3" json:"college,omitempty"` // 学院
Major string `protobuf:"bytes,8,opt,name=major,proto3" json:"major,omitempty"` // 专业
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CetStudentInfo) Reset() {
*x = CetStudentInfo{}
mi := &file_proto_runner_runner_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CetStudentInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CetStudentInfo) ProtoMessage() {}
func (x *CetStudentInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_runner_runner_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CetStudentInfo.ProtoReflect.Descriptor instead.
func (*CetStudentInfo) Descriptor() ([]byte, []int) {
return file_proto_runner_runner_proto_rawDescGZIP(), []int{26}
}
func (x *CetStudentInfo) GetStudentId() string {
if x != nil {
return x.StudentId
}
return ""
}
func (x *CetStudentInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *CetStudentInfo) GetGender() string {
if x != nil {
return x.Gender
}
return ""
}
func (x *CetStudentInfo) GetIdCard() string {
if x != nil {
return x.IdCard
}
return ""
}
func (x *CetStudentInfo) GetDuration() string {
if x != nil {
return x.Duration
}
return ""
}
func (x *CetStudentInfo) GetGrade() string {
if x != nil {
return x.Grade
}
return ""
}
func (x *CetStudentInfo) GetCollege() string {
if x != nil {
return x.College
}
return ""
}
func (x *CetStudentInfo) GetMajor() string {
if x != nil {
return x.Major
}
return ""
}
// CetResultData 四六级成绩结果数据
type CetResultData struct {
state protoimpl.MessageState `protogen:"open.v1"`
StudentInfo *CetStudentInfo `protobuf:"bytes,1,opt,name=student_info,json=studentInfo,proto3" json:"student_info,omitempty"` // 学生信息
Scores []*CetScore `protobuf:"bytes,2,rep,name=scores,proto3" json:"scores,omitempty"` // 成绩列表
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CetResultData) Reset() {
*x = CetResultData{}
mi := &file_proto_runner_runner_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CetResultData) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CetResultData) ProtoMessage() {}
func (x *CetResultData) ProtoReflect() protoreflect.Message {
mi := &file_proto_runner_runner_proto_msgTypes[27]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CetResultData.ProtoReflect.Descriptor instead.
func (*CetResultData) Descriptor() ([]byte, []int) {
return file_proto_runner_runner_proto_rawDescGZIP(), []int{27}
}
func (x *CetResultData) GetStudentInfo() *CetStudentInfo {
if x != nil {
return x.StudentInfo
}
return nil
}
func (x *CetResultData) GetScores() []*CetScore {
if x != nil {
return x.Scores
}
return nil
}
var File_proto_runner_runner_proto protoreflect.FileDescriptor var File_proto_runner_runner_proto protoreflect.FileDescriptor
const file_proto_runner_runner_proto_rawDesc = "" + const file_proto_runner_runner_proto_rawDesc = "" +
@@ -2047,7 +2334,7 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"student_id\x18\x02 \x01(\tR\tstudentId\x12%\n" + "student_id\x18\x02 \x01(\tR\tstudentId\x12%\n" +
"\x06grades\x18\x03 \x03(\v2\r.runner.GradeR\x06grades\x123\n" + "\x06grades\x18\x03 \x03(\v2\r.runner.GradeR\x06grades\x123\n" +
"\vgpa_summary\x18\x04 \x01(\v2\x12.runner.GpaSummaryR\n" + "\vgpa_summary\x18\x04 \x01(\v2\x12.runner.GpaSummaryR\n" +
"gpaSummary\"\xf9\x01\n" + "gpaSummary\"\x96\x02\n" +
"\x05Grade\x12\x12\n" + "\x05Grade\x12\x12\n" +
"\x04term\x18\x01 \x01(\tR\x04term\x12\x1f\n" + "\x04term\x18\x01 \x01(\tR\x04term\x12\x1f\n" +
"\vcourse_code\x18\x02 \x01(\tR\n" + "\vcourse_code\x18\x02 \x01(\tR\n" +
@@ -2060,7 +2347,9 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"\x05score\x18\a \x01(\tR\x05score\x12\x1f\n" + "\x05score\x18\a \x01(\tR\x05score\x12\x1f\n" +
"\vgrade_point\x18\b \x01(\tR\n" + "\vgrade_point\x18\b \x01(\tR\n" +
"gradePoint\x12\x17\n" + "gradePoint\x12\x17\n" +
"\ais_exam\x18\t \x01(\tR\x06isExam\"p\n" + "\ais_exam\x18\t \x01(\tR\x06isExam\x12\x1b\n" +
"\tis_retake\x18\n" +
" \x01(\bR\bisRetake\"p\n" +
"\x0fExamsResultData\x12\x1a\n" + "\x0fExamsResultData\x12\x1a\n" +
"\bsemester\x18\x01 \x01(\tR\bsemester\x12\x1d\n" + "\bsemester\x18\x01 \x01(\tR\bsemester\x12\x1d\n" +
"\n" + "\n" +
@@ -2106,7 +2395,29 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"\aweekday\x18\x02 \x01(\tR\aweekday\x12\x18\n" + "\aweekday\x18\x02 \x01(\tR\aweekday\x12\x18\n" +
"\aperiods\x18\x03 \x01(\tR\aperiods\x12\x12\n" + "\aperiods\x18\x03 \x01(\tR\aperiods\x12\x12\n" +
"\x04term\x18\x04 \x01(\tR\x04term\x12\x14\n" + "\x04term\x18\x04 \x01(\tR\x04term\x12\x14\n" +
"\x05weeks\x18\x05 \x01(\tR\x05weeks*\xc5\x01\n" + "\x05weeks\x18\x05 \x01(\tR\x05weeks\"G\n" +
"\rGetCetPayload\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\"r\n" +
"\bCetScore\x12\x14\n" +
"\x05level\x18\x01 \x01(\tR\x05level\x12\x1b\n" +
"\texam_time\x18\x02 \x01(\tR\bexamTime\x12\x1d\n" +
"\n" +
"ticket_num\x18\x03 \x01(\tR\tticketNum\x12\x14\n" +
"\x05score\x18\x04 \x01(\tR\x05score\"\xd6\x01\n" +
"\x0eCetStudentInfo\x12\x1d\n" +
"\n" +
"student_id\x18\x01 \x01(\tR\tstudentId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
"\x06gender\x18\x03 \x01(\tR\x06gender\x12\x17\n" +
"\aid_card\x18\x04 \x01(\tR\x06idCard\x12\x1a\n" +
"\bduration\x18\x05 \x01(\tR\bduration\x12\x14\n" +
"\x05grade\x18\x06 \x01(\tR\x05grade\x12\x18\n" +
"\acollege\x18\a \x01(\tR\acollege\x12\x14\n" +
"\x05major\x18\b \x01(\tR\x05major\"t\n" +
"\rCetResultData\x129\n" +
"\fstudent_info\x18\x01 \x01(\v2\x16.runner.CetStudentInfoR\vstudentInfo\x12(\n" +
"\x06scores\x18\x02 \x03(\v2\x10.runner.CetScoreR\x06scores*\xdc\x01\n" +
"\bTaskType\x12\x15\n" + "\bTaskType\x12\x15\n" +
"\x11TASK_TYPE_UNKNOWN\x10\x00\x12\x13\n" + "\x11TASK_TYPE_UNKNOWN\x10\x00\x12\x13\n" +
"\x0fTASK_TYPE_LOGIN\x10\x01\x12\x1a\n" + "\x0fTASK_TYPE_LOGIN\x10\x01\x12\x1a\n" +
@@ -2114,7 +2425,8 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"\x14TASK_TYPE_GET_GRADES\x10\x03\x12\x17\n" + "\x14TASK_TYPE_GET_GRADES\x10\x03\x12\x17\n" +
"\x13TASK_TYPE_GET_EXAMS\x10\x04\x12\x1b\n" + "\x13TASK_TYPE_GET_EXAMS\x10\x04\x12\x1b\n" +
"\x17TASK_TYPE_GET_USER_INFO\x10\x05\x12!\n" + "\x17TASK_TYPE_GET_USER_INFO\x10\x05\x12!\n" +
"\x1dTASK_TYPE_GET_EMPTY_CLASSROOM\x10\x06*\x8a\x01\n" + "\x1dTASK_TYPE_GET_EMPTY_CLASSROOM\x10\x06\x12\x15\n" +
"\x11TASK_TYPE_GET_CET\x10\a*\x8a\x01\n" +
"\n" + "\n" +
"TaskStatus\x12\x17\n" + "TaskStatus\x12\x17\n" +
"\x13TASK_STATUS_UNKNOWN\x10\x00\x12\x17\n" + "\x13TASK_STATUS_UNKNOWN\x10\x00\x12\x17\n" +
@@ -2138,7 +2450,7 @@ func file_proto_runner_runner_proto_rawDescGZIP() []byte {
} }
var file_proto_runner_runner_proto_enumTypes = make([]protoimpl.EnumInfo, 2) var file_proto_runner_runner_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_proto_runner_runner_proto_msgTypes = make([]protoimpl.MessageInfo, 25) var file_proto_runner_runner_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
var file_proto_runner_runner_proto_goTypes = []any{ var file_proto_runner_runner_proto_goTypes = []any{
(TaskType)(0), // 0: runner.TaskType (TaskType)(0), // 0: runner.TaskType
(TaskStatus)(0), // 1: runner.TaskStatus (TaskStatus)(0), // 1: runner.TaskStatus
@@ -2166,7 +2478,11 @@ var file_proto_runner_runner_proto_goTypes = []any{
(*GetEmptyClassroomPayload)(nil), // 23: runner.GetEmptyClassroomPayload (*GetEmptyClassroomPayload)(nil), // 23: runner.GetEmptyClassroomPayload
(*EmptyClassroomResultData)(nil), // 24: runner.EmptyClassroomResultData (*EmptyClassroomResultData)(nil), // 24: runner.EmptyClassroomResultData
(*EmptyClassroomEntry)(nil), // 25: runner.EmptyClassroomEntry (*EmptyClassroomEntry)(nil), // 25: runner.EmptyClassroomEntry
nil, // 26: runner.RegisterRequest.CapabilitiesEntry (*GetCetPayload)(nil), // 26: runner.GetCetPayload
(*CetScore)(nil), // 27: runner.CetScore
(*CetStudentInfo)(nil), // 28: runner.CetStudentInfo
(*CetResultData)(nil), // 29: runner.CetResultData
nil, // 30: runner.RegisterRequest.CapabilitiesEntry
} }
var file_proto_runner_runner_proto_depIdxs = []int32{ var file_proto_runner_runner_proto_depIdxs = []int32{
3, // 0: runner.StreamMessage.register:type_name -> runner.RegisterRequest 3, // 0: runner.StreamMessage.register:type_name -> runner.RegisterRequest
@@ -2176,7 +2492,7 @@ var file_proto_runner_runner_proto_depIdxs = []int32{
7, // 4: runner.StreamMessage.task:type_name -> runner.Task 7, // 4: runner.StreamMessage.task:type_name -> runner.Task
9, // 5: runner.StreamMessage.result:type_name -> runner.TaskResult 9, // 5: runner.StreamMessage.result:type_name -> runner.TaskResult
8, // 6: runner.StreamMessage.task_cancel:type_name -> runner.TaskCancel 8, // 6: runner.StreamMessage.task_cancel:type_name -> runner.TaskCancel
26, // 7: runner.RegisterRequest.capabilities:type_name -> runner.RegisterRequest.CapabilitiesEntry 30, // 7: runner.RegisterRequest.capabilities:type_name -> runner.RegisterRequest.CapabilitiesEntry
0, // 8: runner.Task.type:type_name -> runner.TaskType 0, // 8: runner.Task.type:type_name -> runner.TaskType
1, // 9: runner.TaskResult.status:type_name -> runner.TaskStatus 1, // 9: runner.TaskResult.status:type_name -> runner.TaskStatus
15, // 10: runner.ScheduleResultData.courses:type_name -> runner.Course 15, // 10: runner.ScheduleResultData.courses:type_name -> runner.Course
@@ -2185,13 +2501,15 @@ var file_proto_runner_runner_proto_depIdxs = []int32{
17, // 13: runner.GradesResultData.gpa_summary:type_name -> runner.GpaSummary 17, // 13: runner.GradesResultData.gpa_summary:type_name -> runner.GpaSummary
21, // 14: runner.ExamsResultData.exams:type_name -> runner.Exam 21, // 14: runner.ExamsResultData.exams:type_name -> runner.Exam
25, // 15: runner.EmptyClassroomResultData.classrooms:type_name -> runner.EmptyClassroomEntry 25, // 15: runner.EmptyClassroomResultData.classrooms:type_name -> runner.EmptyClassroomEntry
2, // 16: runner.RunnerHub.Connect:input_type -> runner.StreamMessage 28, // 16: runner.CetResultData.student_info:type_name -> runner.CetStudentInfo
2, // 17: runner.RunnerHub.Connect:output_type -> runner.StreamMessage 27, // 17: runner.CetResultData.scores:type_name -> runner.CetScore
17, // [17:18] is the sub-list for method output_type 2, // 18: runner.RunnerHub.Connect:input_type -> runner.StreamMessage
16, // [16:17] is the sub-list for method input_type 2, // 19: runner.RunnerHub.Connect:output_type -> runner.StreamMessage
16, // [16:16] is the sub-list for extension type_name 19, // [19:20] is the sub-list for method output_type
16, // [16:16] is the sub-list for extension extendee 18, // [18:19] is the sub-list for method input_type
0, // [0:16] is the sub-list for field type_name 18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
} }
func init() { file_proto_runner_runner_proto_init() } func init() { file_proto_runner_runner_proto_init() }
@@ -2214,7 +2532,7 @@ func file_proto_runner_runner_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_runner_runner_proto_rawDesc), len(file_proto_runner_runner_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_runner_runner_proto_rawDesc), len(file_proto_runner_runner_proto_rawDesc)),
NumEnums: 2, NumEnums: 2,
NumMessages: 25, NumMessages: 29,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@@ -82,6 +82,7 @@ enum TaskType {
TASK_TYPE_GET_EXAMS = 4; // 获取考试安排 TASK_TYPE_GET_EXAMS = 4; // 获取考试安排
TASK_TYPE_GET_USER_INFO = 5; // 获取用户信息 TASK_TYPE_GET_USER_INFO = 5; // 获取用户信息
TASK_TYPE_GET_EMPTY_CLASSROOM = 6; // 获取空教室 TASK_TYPE_GET_EMPTY_CLASSROOM = 6; // 获取空教室
TASK_TYPE_GET_CET = 7; // 获取四六级成绩
} }
// TaskStatus 任务状态枚举 // TaskStatus 任务状态枚举
@@ -208,6 +209,7 @@ message Grade {
string score = 7; // 成绩 (可能是等级或分数) string score = 7; // 成绩 (可能是等级或分数)
string grade_point = 8; // 绩点 string grade_point = 8; // 绩点
string is_exam = 9; // 是否考试课 string is_exam = 9; // 是否考试课
bool is_retake = 10; // 是否为补考
} }
// ExamsResultData 考试安排结果数据 // ExamsResultData 考试安排结果数据
@@ -264,3 +266,35 @@ message EmptyClassroomEntry {
string term = 4; // 学期描述 string term = 4; // 学期描述
string weeks = 5; // 周次范围 string weeks = 5; // 周次范围
} }
// GetCetPayload 获取四六级成绩任务载荷
message GetCetPayload {
string username = 1; // 学号
string password = 2; // 密码
}
// CetScore 四六级成绩条目
message CetScore {
string level = 1; // 等级(英语四级/英语六级)
string exam_time = 2; // 考试时间,如 2025年06月
string ticket_num = 3; // 准考证号
string score = 4; // 成绩
}
// CetStudentInfo 四六级查询页面的学生信息
message CetStudentInfo {
string student_id = 1; // 学号
string name = 2; // 姓名
string gender = 3; // 性别
string id_card = 4; // 身份证号
string duration = 5; // 学制
string grade = 6; // 年级
string college = 7; // 学院
string major = 8; // 专业
}
// CetResultData 四六级成绩结果数据
message CetResultData {
CetStudentInfo student_info = 1; // 学生信息
repeated CetScore scores = 2; // 成绩列表
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1 // - protoc v6.32.0
// source: proto/runner/runner.proto // source: proto/runner/runner.proto
package runner package runner

66
service/cet.go Normal file
View File

@@ -0,0 +1,66 @@
package service
import (
"context"
"log/slog"
"schedule_converter/client"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
const cetQueryPath = "/sljcjcx/sljcjcxInit"
// GetCET 获取四六级成绩:登录 → 请求四六级查询结果页 → 解析 → 转换为 pb 结果。
func (s *Service) GetCET(ctx context.Context, req *pb.GetCetPayload) (*pb.CetResultData, error) {
log := slog.With("username", req.Username)
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
html, err := client.FetchHTML(ctx, httpClient, "GET", cetQueryPath, nil, "", "")
if err != nil {
return nil, apperr.New("fetch_cet", err, "获取四六级成绩页面失败")
}
cetResult, err := parser.ParseCETHTML(html)
if err != nil {
return nil, apperr.New("parse_cet", err, "解析四六级成绩失败")
}
log.Info("四六级成绩获取成功", "score_count", len(cetResult.Scores))
return &pb.CetResultData{
StudentInfo: convertCETStudentInfo(cetResult.StudentInfo),
Scores: convertCETScores(cetResult.Scores),
}, nil
}
func convertCETStudentInfo(info parser.CETStudentInfo) *pb.CetStudentInfo {
return &pb.CetStudentInfo{
StudentId: info.StudentID,
Name: info.Name,
Gender: info.Gender,
IdCard: info.IDCard,
Duration: info.Duration,
Grade: info.Grade,
College: info.College,
Major: info.Major,
}
}
func convertCETScores(scores []parser.CETScore) []*pb.CetScore {
result := make([]*pb.CetScore, 0, len(scores))
for _, score := range scores {
result = append(result, &pb.CetScore{
Level: score.Level,
ExamTime: score.ExamTime,
TicketNum: score.TicketNum,
Score: score.Score,
})
}
return result
}

77
service/classroom.go Normal file
View File

@@ -0,0 +1,77 @@
package service
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
// GetEmptyClassrooms 获取空教室:登录 → GET 建立会话 → POST 查询 → 解析 → 转换为 pb 结果。
func (s *Service) GetEmptyClassrooms(ctx context.Context, req *pb.GetEmptyClassroomPayload) (*pb.EmptyClassroomResultData, error) {
log := slog.With("username", req.Username, "semester", req.Semester)
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
kjsURL := config.Get().BaseURL + "/kjscx/queryKjs"
// 先 GET 建立会话(沿用原有行为:直接用 client.Get仅取 cookie丢弃 body
sessionReq, err := http.NewRequestWithContext(ctx, "GET", kjsURL, nil)
if err != nil {
return nil, apperr.New("session_classroom", err, "建立空教室会话失败")
}
sessionReq.Header = client.GetHeaders()
sessionResp, err := httpClient.Do(sessionReq)
if err != nil {
return nil, apperr.New("session_classroom", err, "访问空教室页面失败")
}
sessionResp.Body.Close()
// POST 查询参数。
data := fmt.Sprintf("pageXnxq=%s&pageZc1=%s&pageZc2=%s&pageXiaoqu=%s",
req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode)
html, err := client.FetchHTML(ctx, httpClient, "POST", "/kjscx/queryKjs", strings.NewReader(data), "application/x-www-form-urlencoded", "/kjscx/queryKjs")
if err != nil {
return nil, apperr.New("fetch_empty_classrooms", err, "查询空教室失败")
}
entries, err := parser.ParseEmptyClassroomHTML(html, req.Semester, req.WeekStart, req.WeekEnd)
if err != nil {
return nil, apperr.New("parse_empty_classrooms", err, "解析空教室失败")
}
log.Info("空教室获取成功", "classroom_count", len(entries))
return &pb.EmptyClassroomResultData{
StudentId: req.Username,
Semester: req.Semester,
Classrooms: convertEmptyClassrooms(entries),
}, nil
}
// convertEmptyClassrooms 将内部空教室模型转换为 pb.EmptyClassroomEntry 列表。
func convertEmptyClassrooms(entries []models.EmptyClassroomEntry) []*pb.EmptyClassroomEntry {
result := make([]*pb.EmptyClassroomEntry, 0, len(entries))
for _, e := range entries {
result = append(result, &pb.EmptyClassroomEntry{
Classroom: e.Classroom,
Weekday: e.Weekday,
Periods: e.Periods,
Term: e.Term,
Weeks: e.Weeks,
})
}
return result
}

79
service/exams.go Normal file
View File

@@ -0,0 +1,79 @@
package service
import (
"context"
"fmt"
"log/slog"
"strings"
"schedule_converter/client"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
var allExamTypes = []models.ExamType{models.ExamTypeFinal, models.ExamTypeMidterm, models.ExamTypeMakeup}
// GetExams 获取考试安排:登录 → 遍历各考试类型抓取并解析 → 合并 → 转换为 pb 结果。
func (s *Service) GetExams(ctx context.Context, req *pb.GetExamsPayload) (*pb.ExamsResultData, error) {
log := slog.With("username", req.Username, "semester", req.Semester)
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
var allExams []models.ExamEntry
var lastErr error
for _, et := range allExamTypes {
data := fmt.Sprintf("xnxq=&kssjd=%s", parser.ExamTypeCode(et))
html, ferr := client.FetchHTML(ctx, httpClient, "POST", "/kscx/queryKsForXs", strings.NewReader(data), "application/x-www-form-urlencoded", "")
if ferr != nil {
log.Warn("获取考试数据失败", "exam_type", string(et), "error", ferr)
lastErr = ferr
continue
}
exams, perr := parser.ParseExamHTML(html, et)
if perr != nil {
log.Warn("解析考试数据失败", "exam_type", string(et), "error", perr)
lastErr = perr
continue
}
allExams = append(allExams, exams...)
}
if len(allExams) == 0 && lastErr != nil {
return nil, apperr.New("fetch_exams", lastErr, "所有考试类型获取均失败")
}
return &pb.ExamsResultData{
StudentId: req.Username,
Semester: req.Semester,
Exams: convertExams(allExams),
}, nil
}
// convertExams 将内部考试模型转换为 pb.Exam 列表。
func convertExams(exams []models.ExamEntry) []*pb.Exam {
result := make([]*pb.Exam, 0, len(exams))
for _, e := range exams {
examTypeStr := "期末"
switch e.Type {
case models.ExamTypeMidterm:
examTypeStr = "期中"
case models.ExamTypeMakeup:
examTypeStr = "补考"
}
result = append(result, &pb.Exam{
CourseName: e.Course,
ExamType: examTypeStr,
Date: e.Date,
Time: e.Time,
Week: e.Week,
Weekday: e.Weekday,
})
}
return result
}

85
service/grades.go Normal file
View File

@@ -0,0 +1,85 @@
package service
import (
"context"
"log/slog"
"schedule_converter/client"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
// GetGrades 获取成绩:登录 → 优先 GPA 页面 → 回退到成绩页面 → 解析 → 转换为 pb 结果。
func (s *Service) GetGrades(ctx context.Context, req *pb.GetGradesPayload) (*pb.GradesResultData, error) {
log := slog.With("username", req.Username, "semester", req.Semester)
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
var gpaSummary *pb.GpaSummary
gpaHTML, err := client.FetchHTML(ctx, httpClient, "GET", "/xfj/queryListXfj", nil, "", "")
if err != nil {
log.Warn("获取GPA页面失败尝试成绩页面", "error", err)
gradesHTML, ferr := client.FetchHTML(ctx, httpClient, "GET", "/cjcx/queryQmcj", nil, "", "")
if ferr != nil {
return nil, apperr.New("fetch_grades", ferr, "获取成绩失败")
}
grades, perr := parser.ParseGradesHTML(gradesHTML)
if perr != nil {
return nil, apperr.New("parse_grades", perr, "解析成绩失败")
}
return &pb.GradesResultData{
StudentId: req.Username,
Semester: req.Semester,
Grades: convertGrades(grades),
}, nil
}
summary, grades, err := parser.ParseGPAHTML(gpaHTML)
if err != nil {
return nil, apperr.New("parse_gpa", err, "解析学分绩失败")
}
if summary != nil {
gpaSummary = &pb.GpaSummary{
AverageGpa: summary.AverageGPA,
Rank: summary.Rank,
ExamTotalGpa: summary.ExamTotalGPA,
ExamTotalCredits: summary.ExamTotalCredits,
FailCredits: summary.FailCredits,
Semesters: summary.Semesters,
}
}
return &pb.GradesResultData{
StudentId: req.Username,
Semester: req.Semester,
Grades: convertGrades(grades),
GpaSummary: gpaSummary,
}, nil
}
// convertGrades 将内部成绩模型转换为 pb.Grade 列表。
func convertGrades(grades []models.GradeEntry) []*pb.Grade {
result := make([]*pb.Grade, 0, len(grades))
for _, g := range grades {
result = append(result, &pb.Grade{
Term: g.Term,
CourseCode: g.Code,
CourseName: g.Name,
Nature: g.Nature,
Category: g.Category,
Credit: g.Credit,
Score: g.Score,
GradePoint: g.GradePoint,
IsExam: g.IsExam,
IsRetake: g.IsRetake,
})
}
return result
}

11
service/login.go Normal file
View File

@@ -0,0 +1,11 @@
package service
import (
"context"
"net/http"
)
// Login 仅执行登录流程,返回已认证的 HTTP 客户端(调用方可继续用它抓取数据)。
func (s *Service) Login(ctx context.Context, username, password, twfid string) (*http.Client, error) {
return s.newAuthenticatedClient(ctx, username, password, twfid)
}

75
service/schedule.go Normal file
View File

@@ -0,0 +1,75 @@
package service
import (
"context"
"fmt"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
// GetSchedule 获取课表:登录 → POST 查询 → 解析 → 转换为 pb 结果。
func (s *Service) GetSchedule(ctx context.Context, req *pb.GetSchedulePayload) (*pb.ScheduleResultData, error) {
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
semester := req.Semester
if semester == "" {
semester = config.Get().Semester
}
data := fmt.Sprintf("xnxq=%s", semester)
html, err := client.FetchHTML(ctx, httpClient, "POST", "/kbcx/queryGrkb", strings.NewReader(data), "application/x-www-form-urlencoded", "")
if err != nil {
return nil, apperr.New("fetch_schedule", err, "获取课表失败")
}
courses, err := parser.ParseScheduleHTML(html)
if err != nil {
return nil, apperr.New("parse_schedule", err, "解析课表失败")
}
return &pb.ScheduleResultData{
StudentId: req.Username,
Semester: semester,
Courses: convertCourses(courses),
}, nil
}
// convertCourses 将内部课程模型转换为 pb.Course 列表。
func convertCourses(courses []models.Course) []*pb.Course {
result := make([]*pb.Course, len(courses))
for i, c := range courses {
schedule := make([]*pb.ScheduleTime, len(c.Schedule))
for j, sc := range c.Schedule {
schedule[j] = &pb.ScheduleTime{
Day: sc.Day,
Sections: toInt32Slice(sc.Section),
Weeks: toInt32Slice(sc.Weeks),
}
}
result[i] = &pb.Course{
Name: c.Name,
Teacher: c.Teacher,
Room: c.Room,
Schedule: schedule,
}
}
return result
}
func toInt32Slice(arr []int) []int32 {
result := make([]int32, len(arr))
for i, v := range arr {
result[i] = int32(v)
}
return result
}

View File

@@ -1,8 +1,7 @@
package grpc package service
import ( import (
"context" "context"
"encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
@@ -13,8 +12,6 @@ import (
"schedule_converter/captcha" "schedule_converter/captcha"
"schedule_converter/client" "schedule_converter/client"
"schedule_converter/config" "schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors" apperr "schedule_converter/pkg/errors"
) )
@@ -23,19 +20,34 @@ const (
loginRetryInterval = 3 * time.Second loginRetryInterval = 3 * time.Second
) )
func createHTTPClient() (*http.Client, error) { // Service 封装教务系统的登录与数据抓取编排逻辑。
type Service struct{}
// NewService 创建 service。
func NewService() *Service {
return &Service{}
}
// newAuthenticatedClient 创建带 cookie jar 的 HTTP 客户端,设置 TWFID cookie 并完成登录。
func (s *Service) newAuthenticatedClient(ctx context.Context, username, password, twfid string) (*http.Client, error) {
jar, err := cookiejar.New(nil) jar, err := cookiejar.New(nil)
if err != nil { if err != nil {
return nil, apperr.New("create_jar", err, "创建 cookie jar 失败") return nil, apperr.New("create_jar", err, "创建 cookie jar 失败")
} }
return &http.Client{Jar: jar}, nil httpClient := &http.Client{Jar: jar}
if twfid == "" {
twfid = config.Get().TWFID
}
s.setTWFIDCookie(httpClient, config.Get().BaseURL, twfid)
if err := s.login(ctx, httpClient, username, password); err != nil {
return nil, err
}
return httpClient, nil
} }
func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { func (s *Service) setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
cfg := config.Get()
if twfid == "" {
twfid = cfg.TWFID
}
u, err := url.Parse(baseURL) u, err := url.Parse(baseURL)
if err != nil { if err != nil {
slog.Warn("解析 baseURL 失败,无法设置 TWFID cookie", "base_url", baseURL, "error", err) slog.Warn("解析 baseURL 失败,无法设置 TWFID cookie", "base_url", baseURL, "error", err)
@@ -46,7 +58,8 @@ func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
}) })
} }
func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error { // login 执行登录流程,验证码固定使用占位值 "...."。
func (s *Service) login(ctx context.Context, httpClient *http.Client, username, password string) error {
log := slog.With("username", username) log := slog.With("username", username)
if err := client.GetInitialCookiesWithClient(httpClient); err != nil { if err := client.GetInitialCookiesWithClient(httpClient); err != nil {
@@ -62,14 +75,8 @@ func executeLogin(ctx context.Context, httpClient *http.Client, username, passwo
log.Info("尝试登录", "attempt", attempt, "max_retries", maxLoginRetries) log.Info("尝试登录", "attempt", attempt, "max_retries", maxLoginRetries)
captchaCode, err := captcha.GetAndRecognize(httpClient) captchaCode := captcha.GetCaptchaCode()
if err != nil { success, err := auth.LoginWithClient(ctx, httpClient, captchaCode, username, password)
log.Warn("验证码获取失败", "error", err, "attempt", attempt)
time.Sleep(loginRetryInterval)
continue
}
success, err := auth.LoginWithClient(httpClient, captchaCode, username, password)
if err != nil { if err != nil {
log.Warn("登录请求失败", "error", err, "attempt", attempt) log.Warn("登录请求失败", "error", err, "attempt", attempt)
time.Sleep(loginRetryInterval) time.Sleep(loginRetryInterval)
@@ -86,23 +93,3 @@ func executeLogin(ctx context.Context, httpClient *http.Client, username, passwo
return apperr.ErrLoginFailed return apperr.ErrLoginFailed
} }
func executeLoginAndFetchSchedule(ctx context.Context, httpClient *http.Client, username, password, semester string) ([]models.Course, error) {
if err := executeLogin(ctx, httpClient, username, password); err != nil {
return nil, err
}
cfg := config.Get()
if semester == "" {
semester = cfg.Semester
}
return parser.FetchScheduleWithClient(httpClient, semester)
}
func unmarshalPayload(payload []byte, v any) error {
if err := json.Unmarshal(payload, v); err != nil {
return apperr.New("parse_payload", err, "解析任务载荷失败")
}
return nil
}