Merge remote-tracking branch 'origin/master'

# Conflicts:
#	config/config.go
#	grpc/convert.go
#	grpc/handler.go
#	parser/score.go
#	service/service.go
This commit is contained in:
WuYuuuub
2026-06-17 13:31:12 +08:00
28 changed files with 753 additions and 783 deletions

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

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

View File

@@ -1,6 +1,7 @@
package auth
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
}

View File

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

47
client/fetch.go Normal file
View File

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

View File

@@ -1,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
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("登录成功")

View File

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

10
models/empty_classroom.go Normal file
View File

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

20
models/exam.go Normal file
View File

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

32
models/grade.go Normal file
View File

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -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中表现为 <font color="#0066ff">补考</font>
// 解析后该单元格文本为"补考"需要从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())
// 检测补考标记:<font color="#0066ff">补考</font>
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) {

View File

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

77
service/classroom.go Normal file
View File

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

79
service/exams.go Normal file
View File

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

84
service/grades.go Normal file
View File

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

11
service/login.go Normal file
View File

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

75
service/schedule.go Normal file
View File

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

101
service/service.go Normal file
View File

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