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