diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000..abded7b --- /dev/null +++ b/.opencode/package-lock.json @@ -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" + } + } + } +} diff --git a/auth/login.go b/auth/login.go index dca0267..d9714b1 100644 --- a/auth/login.go +++ b/auth/login.go @@ -1,6 +1,7 @@ package auth import ( + "context" "fmt" "io" "log/slog" @@ -14,13 +15,13 @@ import ( var nameRegexp = regexp.MustCompile(`(\w+)同学`) -// LoginWithClient 使用指定的 HTTP 客户端和凭据登录 -func LoginWithClient(httpClient *http.Client, captchaCode, username, password string) (bool, error) { +// LoginWithClient 使用指定的 HTTP 客户端和凭据登录。 +func LoginWithClient(ctx context.Context, httpClient *http.Client, captchaCode, username, password string) (bool, error) { log := slog.With("username", username) log.Debug("执行登录") 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 { return false, err } @@ -54,4 +55,4 @@ func LoginWithClient(httpClient *http.Client, captchaCode, username, password st log.Warn("登录失败") } return false, nil -} \ No newline at end of file +} diff --git a/parser/encoding.go b/client/encoding.go similarity index 84% rename from parser/encoding.go rename to client/encoding.go index 85b56ed..f964035 100644 --- a/parser/encoding.go +++ b/client/encoding.go @@ -1,8 +1,7 @@ -package parser +package client import ( "bytes" - "encoding/json" "io" "net/http" "strings" @@ -11,7 +10,8 @@ import ( "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) if err != nil { return "", err @@ -73,11 +73,3 @@ func isValidUTF8(data []byte) bool { } 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) -} \ No newline at end of file diff --git a/client/fetch.go b/client/fetch.go new file mode 100644 index 0000000..f91877f --- /dev/null +++ b/client/fetch.go @@ -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) +} diff --git a/config/config.go b/config/config.go index a4710aa..b0c5462 100644 --- a/config/config.go +++ b/config/config.go @@ -1,17 +1,24 @@ package config import ( + "log/slog" "os" - "strconv" "sync" + "time" ) type Config struct { BaseURL string - Username string - Password string Semester string TWFID string + Recognizer RecognizerConfig +} + +type RecognizerConfig struct { + APIURL string + APIKey string + Model string + Timeout time.Duration } var ( @@ -23,10 +30,18 @@ func Load() *Config { cfgOnce.Do(func() { cfg = &Config{ BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"), - Username: getEnv("JWTS_USERNAME", "2024212193"), - Password: getEnv("JWTS_PASSWORD", "wyb12580963"), - Semester: getEnv("JWTS_SEMESTER", "2025-20262"), + Semester: getEnv("JWTS_SEMESTER", ""), TWFID: getEnv("JWTS_TWFID", ""), + Recognizer: RecognizerConfig{ + APIURL: getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions"), + APIKey: getEnv("RECOGNIZER_API_KEY", ""), + 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 @@ -47,21 +62,22 @@ func getEnv(key, defaultValue string) string { return val } -func getEnvInt(key string, defaultValue int) int { +func getEnvDuration(key string, defaultValue time.Duration) time.Duration { val := os.Getenv(key) if val == "" { return defaultValue } - if i, err := strconv.Atoi(val); err == nil { - return i + if d, err := time.ParseDuration(val); err == nil { + return d } return defaultValue } var ( - BaseURL = Get().BaseURL - Username = Get().Username - Password = Get().Password - Semester = Get().Semester - TWFID = Get().TWFID + BaseURL = Get().BaseURL + Semester = Get().Semester + TWFID = Get().TWFID + RecognizerAPIURL = Get().Recognizer.APIURL + RecognizerAPIKey = Get().Recognizer.APIKey + RecognizerModel = Get().Recognizer.Model ) diff --git a/grpc/client.go b/grpc/client.go index 7adde41..0506cc7 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -78,18 +78,16 @@ func (c *Client) Connect(ctx context.Context) error { // register 发送注册请求 func (c *Client) register() error { + capabilities := make(map[string]string, len(supportedTaskTypes())) + for _, t := range supportedTaskTypes() { + capabilities[t.String()] = "true" + } msg := &pb.StreamMessage{ Message: &pb.StreamMessage_Register{ Register: &pb.RegisterRequest{ - RunnerId: c.runnerID, - 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", - "TASK_TYPE_GET_EMPTY_CLASSROOM": "true", - }, + RunnerId: c.runnerID, + Version: c.version, + Capabilities: capabilities, }, }, } diff --git a/grpc/convert.go b/grpc/convert.go deleted file mode 100644 index 30490c8..0000000 --- a/grpc/convert.go +++ /dev/null @@ -1,118 +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, - IsRetake: g.IsRetake, - }) - } - 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 -} - -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 _, s := range scores { - result = append(result, &pb.CetScore{ - Level: s.Level, - ExamTime: s.ExamTime, - TicketNum: s.TicketNum, - Score: s.Score, - }) - } - return result -} diff --git a/grpc/handler.go b/grpc/handler.go index fc4050d..3daf44c 100644 --- a/grpc/handler.go +++ b/grpc/handler.go @@ -71,8 +71,6 @@ func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, erro return h.login(ctx, task.Payload) case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM: return h.getEmptyClassrooms(ctx, task.Payload) - case pb.TaskType_TASK_TYPE_GET_CET: - return h.getCET(ctx, task.Payload) default: return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type) } diff --git a/grpc/handler_classroom.go b/grpc/handler_classroom.go index 30dcd01..2bbda33 100644 --- a/grpc/handler_classroom.go +++ b/grpc/handler_classroom.go @@ -7,8 +7,6 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" - "schedule_converter/parser" 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.Info("获取空教室任务") - httpClient, err := createHTTPClient() + result, err := h.service.GetEmptyClassrooms(ctx, &req) if err != nil { - return nil, err + return nil, apperr.New("get_empty_classrooms", err, "获取空教室失败") } - cfg := config.Get() - 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)) + log.Info("空教室获取成功", "classroom_count", len(result.Classrooms)) return json.Marshal(result) } diff --git a/grpc/handler_exams.go b/grpc/handler_exams.go index c350959..3bf734c 100644 --- a/grpc/handler_exams.go +++ b/grpc/handler_exams.go @@ -7,8 +7,6 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" - "schedule_converter/parser" 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.Info("获取考试安排任务") - httpClient, err := createHTTPClient() + result, err := h.service.GetExams(ctx, &req) if err != nil { - return nil, err + return nil, apperr.New("get_exams", 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)) + log.Info("考试安排获取成功", "exam_count", len(result.Exams)) return json.Marshal(result) } diff --git a/grpc/handler_grades.go b/grpc/handler_grades.go index 7bad40f..0041a52 100644 --- a/grpc/handler_grades.go +++ b/grpc/handler_grades.go @@ -7,8 +7,6 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" - "schedule_converter/parser" 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.Info("获取成绩任务") - httpClient, err := createHTTPClient() + result, err := h.service.GetGrades(ctx, &req) if err != nil { - return nil, err + return nil, apperr.New("get_grades", err, "获取成绩失败") } - cfg := config.Get() - 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)) + log.Info("成绩获取成功", "grade_count", len(result.Grades)) return json.Marshal(result) } diff --git a/grpc/handler_helpers.go b/grpc/handler_helpers.go deleted file mode 100644 index 4cf37d7..0000000 --- a/grpc/handler_helpers.go +++ /dev/null @@ -1,102 +0,0 @@ -package grpc - -import ( - "context" - "encoding/json" - "log/slog" - "net/http" - "net/http/cookiejar" - "net/url" - "time" - - "schedule_converter/auth" - "schedule_converter/captcha" - "schedule_converter/client" - "schedule_converter/config" - "schedule_converter/models" - "schedule_converter/parser" - apperr "schedule_converter/pkg/errors" -) - -const ( - maxLoginRetries = 3 - loginRetryInterval = 3 * time.Second -) - -func createHTTPClient() (*http.Client, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return nil, apperr.New("create_jar", err, "鍒涘缓 cookie jar 澶辫触") - } - return &http.Client{Jar: jar}, nil -} - -func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { - cfg := config.Get() - if twfid == "" { - twfid = cfg.TWFID - } - u, err := url.Parse(baseURL) - if err != nil { - slog.Warn("瑙f瀽 baseURL 澶辫触锛屾棤娉曡缃?TWFID cookie", "base_url", baseURL, "error", err) - return - } - httpClient.Jar.SetCookies(u, []*http.Cookie{ - {Name: "TWFID", Value: twfid}, - }) -} - -func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error { - log := slog.With("username", username) - - if err := client.GetInitialCookiesWithClient(httpClient); err != nil { - return apperr.New("get_cookies", err, "鑾峰彇鍒濆 Cookie 澶辫触") - } - - for attempt := 1; attempt <= maxLoginRetries; attempt++ { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - log.Info("灏濊瘯鐧诲綍", "attempt", attempt, "max_retries", maxLoginRetries) - - captchaCode := captcha.GetCaptchaCode() - success, err := auth.LoginWithClient(httpClient, captchaCode, username, password) - if err != nil { - log.Warn("鐧诲綍璇锋眰澶辫触", "error", err, "attempt", attempt) - time.Sleep(loginRetryInterval) - continue - } - - if success { - return nil - } - - log.Warn("鐧诲綍楠岃瘉澶辫触", "attempt", attempt) - time.Sleep(loginRetryInterval) - } - - 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, "瑙f瀽浠诲姟杞借嵎澶辫触") - } - return nil -} diff --git a/grpc/handler_login.go b/grpc/handler_login.go index ad98ea0..fde3332 100644 --- a/grpc/handler_login.go +++ b/grpc/handler_login.go @@ -7,7 +7,7 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" + apperr "schedule_converter/pkg/errors" ) 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.Info("登录任务") - httpClient, err := createHTTPClient() - if err != nil { - 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 + if _, err := h.service.Login(ctx, req.Username, req.Password, req.Twfid); err != nil { + return nil, apperr.New("login", err, "登录失败") } log.Info("登录成功") diff --git a/grpc/handler_schedule.go b/grpc/handler_schedule.go index 8bd4d54..e2df216 100644 --- a/grpc/handler_schedule.go +++ b/grpc/handler_schedule.go @@ -7,7 +7,7 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" + apperr "schedule_converter/pkg/errors" ) 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.Info("获取课表任务") - httpClient, err := createHTTPClient() + result, err := h.service.GetSchedule(ctx, &req) if err != nil { - return nil, err + return nil, apperr.New("get_schedule", err, "获取课表失败") } - cfg := config.Get() - 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)) + log.Info("课表获取成功", "course_count", len(result.Courses)) return json.Marshal(result) } diff --git a/models/empty_classroom.go b/models/empty_classroom.go new file mode 100644 index 0000000..3aace3a --- /dev/null +++ b/models/empty_classroom.go @@ -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"` +} diff --git a/models/exam.go b/models/exam.go new file mode 100644 index 0000000..1a30f72 --- /dev/null +++ b/models/exam.go @@ -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 +} diff --git a/models/grade.go b/models/grade.go new file mode 100644 index 0000000..fe54137 --- /dev/null +++ b/models/grade.go @@ -0,0 +1,32 @@ +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 +} + +// GPASummary 学分绩概要 +type GPASummary struct { + AverageGPA string + Rank string + ExamTotalGPA string + ExamTotalCredits string + FailCredits string + Semesters string +} diff --git a/parser/empty_classroom.go b/parser/empty_classroom.go index 68f591d..d55e459 100644 --- a/parser/empty_classroom.go +++ b/parser/empty_classroom.go @@ -2,82 +2,15 @@ package parser import ( "fmt" - "log/slog" - "net/http" "strings" - "schedule_converter/client" - "schedule_converter/config" + "schedule_converter/models" "github.com/PuerkitoBio/goquery" ) -type EmptyClassroomEntry struct { - Classroom string `json:"classroom"` - 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) { +// ParseEmptyClassroomHTML 解析空教室查询结果 HTML,返回空教室条目列表。 +func ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) { if html == "" { return nil, nil } @@ -94,7 +27,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty dayNames := [7]string{"星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"} 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" 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 != "" { weekStr = fmt.Sprintf("%s-%s周", weekStart, weekEnd) } - entry := EmptyClassroomEntry{ + entry := models.EmptyClassroomEntry{ Classroom: classroomName, Weekday: strings.Join(weekdays, ", "), Periods: strings.Join(slots, "\n"), @@ -216,69 +149,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty 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 -func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) { - return parseEmptyClassroomHTML(html, semester, weekStart, weekEnd) +func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) { + return ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd) } diff --git a/parser/exam.go b/parser/exam.go index 8359e21..24ca691 100644 --- a/parser/exam.go +++ b/parser/exam.go @@ -1,107 +1,38 @@ package parser import ( - "fmt" - "log/slog" - "net/http" "strings" - "schedule_converter/client" - "schedule_converter/config" + "schedule_converter/models" "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 { +// examTypeToCode 将考试类型映射为教务系统的请求码。 +func examTypeToCode(t models.ExamType) string { switch t { - case ExamTypeMidterm: + case models.ExamTypeMidterm: return "02" - case ExamTypeMakeup: + case models.ExamTypeMakeup: return "03" default: return "01" } } -func FetchExams(httpClient *http.Client, examType ExamType) ([]ExamEntry, error) { - examURL := config.Get().BaseURL + "/kscx/queryKsForXs" - log := slog.With("exam_type", string(examType)) - 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 +// ExamTypeCode 返回考试类型对应的请求码(供 client 层构造请求使用)。 +func ExamTypeCode(t models.ExamType) string { + return examTypeToCode(t) } -func FetchAllExams(httpClient *http.Client) ([]ExamEntry, error) { - var allExams []ExamEntry - 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) { +// ParseExamHTML 解析考试安排页面 HTML,返回考试条目列表。 +func ParseExamHTML(html string, examType models.ExamType) ([]models.ExamEntry, error) { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { return nil, err } - var exams []ExamEntry + var exams []models.ExamEntry doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { rows := table.Find("tr") @@ -120,7 +51,7 @@ func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) { texts := getCellTexts(cells) - exam := ExamEntry{ + exam := models.ExamEntry{ Course: safeGet(texts, 1), Date: safeGet(texts, 2), Time: safeGet(texts, 3), @@ -136,4 +67,4 @@ func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) { }) return exams, nil -} \ No newline at end of file +} diff --git a/parser/schedule.go b/parser/schedule.go index d660c11..f5beea1 100644 --- a/parser/schedule.go +++ b/parser/schedule.go @@ -1,16 +1,11 @@ package parser import ( - "fmt" - "io" - "net/http" "regexp" "sort" "strconv" "strings" - "schedule_converter/client" - "schedule_converter/config" "schedule_converter/models" "github.com/PuerkitoBio/goquery" @@ -23,32 +18,6 @@ type weekRoomEntry struct { room string } -// 获取课表 -// FetchScheduleWithClient 使用指定的 HTTP 客户端和学期获取课表 -func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models.Course, error) { - // POST请求获取课表 - data := fmt.Sprintf("xnxq=%s", semester) - req, err := http.NewRequest("POST", config.Get().BaseURL+"/kbcx/queryGrkb", strings.NewReader(data)) - if err != nil { - return nil, err - } - - req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - return ParseScheduleHTML(string(body)) -} - // 解析课表HTML func ParseScheduleHTML(html string) ([]models.Course, error) { // 创建一个带Reader的Document diff --git a/parser/score.go b/parser/score.go index cc9ef65..4d52e4a 100644 --- a/parser/score.go +++ b/parser/score.go @@ -1,129 +1,21 @@ package parser import ( - "fmt" - "log/slog" - "net/http" "strings" - "schedule_converter/client" - "schedule_converter/config" + "schedule_converter/models" "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 - IsRetake bool -} - -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) { +// ParseGradesHTML 解析成绩页面 HTML,返回成绩条目列表。 +func ParseGradesHTML(html string) ([]models.GradeEntry, error) { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { return nil, err } - var grades []GradeEntry + var grades []models.GradeEntry doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { rows := table.Find("tr") @@ -140,19 +32,18 @@ func parseGradesHTML(html string) ([]GradeEntry, error) { return } - texts, isRetake := getCellTextsAndRetake(cells) + 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), - IsRetake: isRetake, + entry := models.GradeEntry{ + Term: safeGet(texts, 1), + Code: safeGet(texts, 3), + Name: safeGet(texts, 4), + Credit: safeGet(texts, 7), } if isMidterm { @@ -185,13 +76,14 @@ func parseGradesHTML(html string) ([]GradeEntry, error) { 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)) if err != nil { return nil, nil, err } - summary := &GPASummary{} + summary := &models.GPASummary{} doc.Find("span").Each(func(_ int, span *goquery.Selection) { id, _ := span.Attr("id") @@ -212,7 +104,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) { rows := table.Find("tr") @@ -229,9 +121,9 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) { return } - texts, isRetake := getCellTextsAndRetake(cells) + texts := getCellTexts(cells) - entry := GradeEntry{ + entry := models.GradeEntry{ Term: safeGet(texts, 0), Code: safeGet(texts, 1), Name: safeGet(texts, 2), @@ -241,7 +133,6 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) { Credit: safeGet(texts, 7), Score: safeGet(texts, 8), GradePoint: safeGet(texts, 9), - IsRetake: isRetake, } if entry.Name != "" && len(entry.Name) >= 2 { @@ -253,32 +144,6 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) { return summary, grades, nil } -// getCellTextsAndRetake 解析单元格文本,同时检测是否包含补考标记 -// 补考标记在HTML中表现为 补考, -// 解析后该单元格文本为"补考",需要从texts中移除并记录isRetake标志 -func getCellTextsAndRetake(cells *goquery.Selection) ([]string, bool) { - var texts []string - isRetake := false - retakeIndex := -1 - - cells.Each(func(i int, cell *goquery.Selection) { - text := strings.TrimSpace(cell.Text()) - // 检测补考标记:补考 - if text == "补考" { - isRetake = true - retakeIndex = i - } - texts = append(texts, text) - }) - - // 移除"补考"这一列,使后续列索引与正常行一致 - if isRetake && retakeIndex >= 0 { - texts = append(texts[:retakeIndex], texts[retakeIndex+1:]...) - } - - return texts, isRetake -} - func getCellTexts(cells *goquery.Selection) []string { var texts []string cells.Each(func(_ int, cell *goquery.Selection) { diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 96ce1cf..4e1c4c1 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -7,25 +7,26 @@ import ( var ErrLoginFailed = errors.New("login failed") -type ScheduleError struct { +// AppError 携带操作名(Op)与中文提示(Msg)的错误类型,用于统一日志与错误上报。 +type AppError struct { Op string Err error Msg string } -func (e *ScheduleError) Error() string { +func (e *AppError) Error() string { if e.Msg != "" { return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) } return fmt.Sprintf("%s: %v", e.Op, e.Err) } -func (e *ScheduleError) Unwrap() error { +func (e *AppError) Unwrap() error { return e.Err } func New(op string, err error, msg string) error { - return &ScheduleError{ + return &AppError{ Op: op, Err: err, Msg: msg, diff --git a/service/classroom.go b/service/classroom.go new file mode 100644 index 0000000..ffed707 --- /dev/null +++ b/service/classroom.go @@ -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 +} diff --git a/service/exams.go b/service/exams.go new file mode 100644 index 0000000..defd791 --- /dev/null +++ b/service/exams.go @@ -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 +} diff --git a/service/grades.go b/service/grades.go new file mode 100644 index 0000000..2918a25 --- /dev/null +++ b/service/grades.go @@ -0,0 +1,84 @@ +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, + }) + } + return result +} diff --git a/service/login.go b/service/login.go new file mode 100644 index 0000000..b323797 --- /dev/null +++ b/service/login.go @@ -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) +} diff --git a/service/schedule.go b/service/schedule.go new file mode 100644 index 0000000..9b1ff87 --- /dev/null +++ b/service/schedule.go @@ -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 +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..76f1a6d --- /dev/null +++ b/service/service.go @@ -0,0 +1,101 @@ +package service + +import ( + "context" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/url" + "time" + + "schedule_converter/auth" + "schedule_converter/captcha" + "schedule_converter/client" + "schedule_converter/config" + apperr "schedule_converter/pkg/errors" +) + +const ( + maxLoginRetries = 3 + loginRetryInterval = 3 * time.Second +) + +// 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) + if err != nil { + return nil, apperr.New("create_jar", err, "创建 cookie jar 失败") + } + 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 (s *Service) setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { + u, err := url.Parse(baseURL) + if err != nil { + slog.Warn("解析 baseURL 失败,无法设置 TWFID cookie", "base_url", baseURL, "error", err) + return + } + httpClient.Jar.SetCookies(u, []*http.Cookie{ + {Name: "TWFID", Value: twfid}, + }) +} + +// login 执行登录流程,包含验证码识别与重试。 +func (s *Service) login(ctx context.Context, httpClient *http.Client, username, password string) error { + log := slog.With("username", username) + + if err := client.GetInitialCookiesWithClient(httpClient); err != nil { + return apperr.New("get_cookies", err, "获取初始 Cookie 失败") + } + + for attempt := 1; attempt <= maxLoginRetries; attempt++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + log.Info("尝试登录", "attempt", attempt, "max_retries", maxLoginRetries) + + captchaCode, err := captcha.GetAndRecognize(httpClient) + if err != nil { + log.Warn("验证码获取失败", "error", err, "attempt", attempt) + time.Sleep(loginRetryInterval) + continue + } + + success, err := auth.LoginWithClient(ctx, httpClient, captchaCode, username, password) + if err != nil { + log.Warn("登录请求失败", "error", err, "attempt", attempt) + time.Sleep(loginRetryInterval) + continue + } + + if success { + return nil + } + + log.Warn("登录验证失败", "attempt", attempt) + time.Sleep(loginRetryInterval) + } + + return apperr.ErrLoginFailed +}