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:
115
.opencode/package-lock.json
generated
Normal file
115
.opencode/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
47
client/fetch.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
118
grpc/convert.go
118
grpc/convert.go
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("登录成功")
|
||||
|
||||
@@ -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
10
models/empty_classroom.go
Normal 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
20
models/exam.go
Normal 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
32
models/grade.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
167
parser/score.go
167
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中表现为 <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) {
|
||||
|
||||
@@ -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
77
service/classroom.go
Normal 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
79
service/exams.go
Normal 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
84
service/grades.go
Normal 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
11
service/login.go
Normal 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
75
service/schedule.go
Normal 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
101
service/service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user