From 4024f9eae0ba0cae4255789a1c914b07d344af1c Mon Sep 17 00:00:00 2001 From: WuYuuuub <625806558@qq.com> Date: Wed, 17 Jun 2026 14:48:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BA=86ai=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E9=83=A8=E5=88=86=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=B8=AA=E4=BA=BA=E6=88=90=E7=BB=A9=E8=A1=A5=E8=80=83?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E5=92=8Ccet=E6=88=90=E7=BB=A9=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- captcha/recognize.go | 4 +- client/http.go | 2 +- config/config.go | 40 ++--------------- grpc/handler.go | 29 ++++++++++++- main.go | 2 +- models/grade.go | 1 + parser/score.go | 100 ++++++++++++++++++++++++++++++------------- service/grades.go | 1 + service/service.go | 10 +---- 9 files changed, 110 insertions(+), 79 deletions(-) diff --git a/captcha/recognize.go b/captcha/recognize.go index 74de1cf..bb36709 100644 --- a/captcha/recognize.go +++ b/captcha/recognize.go @@ -1,7 +1,7 @@ package captcha -// GetCaptchaCode 返回固定验证码 -// 教务系统验证码已不再需要识别,直接使用固定值 +// GetCaptchaCode 返回固定验证码占位值。 +// 教务系统验证码不再需要 AI 识别,登录时直接提交固定占位值 "...."。 func GetCaptchaCode() string { return "...." } diff --git a/client/http.go b/client/http.go index 199da1b..4b7e357 100644 --- a/client/http.go +++ b/client/http.go @@ -49,4 +49,4 @@ func CheckLoginSuccess(body string) bool { func IsCaptchaError(body string) bool { return strings.Contains(body, "验证码") -} \ No newline at end of file +} diff --git a/config/config.go b/config/config.go index b0c5462..4ef2945 100644 --- a/config/config.go +++ b/config/config.go @@ -1,24 +1,14 @@ package config import ( - "log/slog" "os" "sync" - "time" ) type Config struct { BaseURL string Semester string TWFID string - Recognizer RecognizerConfig -} - -type RecognizerConfig struct { - APIURL string - APIKey string - Model string - Timeout time.Duration } var ( @@ -32,16 +22,6 @@ func Load() *Config { BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"), 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 @@ -62,22 +42,8 @@ func getEnv(key, defaultValue string) string { return val } -func getEnvDuration(key string, defaultValue time.Duration) time.Duration { - val := os.Getenv(key) - if val == "" { - return defaultValue - } - if d, err := time.ParseDuration(val); err == nil { - return d - } - return defaultValue -} - var ( - BaseURL = Get().BaseURL - Semester = Get().Semester - TWFID = Get().TWFID - RecognizerAPIURL = Get().Recognizer.APIURL - RecognizerAPIKey = Get().Recognizer.APIKey - RecognizerModel = Get().Recognizer.Model + BaseURL = Get().BaseURL + Semester = Get().Semester + TWFID = Get().TWFID ) diff --git a/grpc/handler.go b/grpc/handler.go index 3daf44c..f7575ea 100644 --- a/grpc/handler.go +++ b/grpc/handler.go @@ -2,21 +2,46 @@ package grpc import ( "context" + "encoding/json" "errors" "fmt" "log/slog" "sync" "time" + "schedule_converter/service" + pb "schedule_converter/proto/runner" ) type TaskHandler struct { cancelledTasks sync.Map + service *service.Service } func NewTaskHandler() *TaskHandler { - return &TaskHandler{} + return &TaskHandler{service: service.NewService()} +} + +func supportedTaskTypes() []pb.TaskType { + return []pb.TaskType{ + pb.TaskType_TASK_TYPE_LOGIN, + pb.TaskType_TASK_TYPE_GET_SCHEDULE, + pb.TaskType_TASK_TYPE_GET_GRADES, + pb.TaskType_TASK_TYPE_GET_EXAMS, + pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM, + pb.TaskType_TASK_TYPE_GET_CET, + } +} + +func unmarshalPayload(payload []byte, v any) error { + if len(payload) == 0 { + return fmt.Errorf("任务 payload 为空") + } + if err := json.Unmarshal(payload, v); err != nil { + return fmt.Errorf("解析任务 payload 失败: %w", err) + } + return nil } func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult { @@ -71,6 +96,8 @@ func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, erro return h.login(ctx, task.Payload) case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM: return h.getEmptyClassrooms(ctx, task.Payload) + case pb.TaskType_TASK_TYPE_GET_CET: + return h.getCET(ctx, task.Payload) default: return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type) } diff --git a/main.go b/main.go index 9094cd7..2e511a5 100644 --- a/main.go +++ b/main.go @@ -124,4 +124,4 @@ func runGRPCClient(serverAddr string) { } time.Sleep(initialReconnectInterval) } -} \ No newline at end of file +} diff --git a/models/grade.go b/models/grade.go index fe54137..d69222d 100644 --- a/models/grade.go +++ b/models/grade.go @@ -19,6 +19,7 @@ type GradeEntry struct { Score string GradePoint string IsExam string + IsRetake bool } // GPASummary 学分绩概要 diff --git a/parser/score.go b/parser/score.go index 4d52e4a..8bb6985 100644 --- a/parser/score.go +++ b/parser/score.go @@ -39,29 +39,13 @@ func ParseGradesHTML(html string) ([]models.GradeEntry, error) { isMidterm := len(texts) == 10 - entry := models.GradeEntry{ - Term: safeGet(texts, 1), - Code: safeGet(texts, 3), - Name: safeGet(texts, 4), - Credit: safeGet(texts, 7), - } + entry := parseGradeRowTexts(texts, detectRetake(row)) - if isMidterm { + if isMidterm && entry.Score == "" { entry.Score = safeGet(texts, 8) if len(texts) > 9 { entry.GradePoint = safeGet(texts, 9) } - } else { - if len(texts) >= 13 { - entry.Nature = safeGet(texts, 3) - entry.Category = safeGet(texts, 4) - entry.Score = safeGet(texts, 12) - entry.GradePoint = safeGet(texts, 9) - entry.IsExam = safeGet(texts, 6) - } else if len(texts) >= 10 { - entry.Score = safeGet(texts, 8) - entry.GradePoint = safeGet(texts, 9) - } } if entry.Name != "" && len(entry.Name) >= 2 { @@ -123,17 +107,7 @@ func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error) texts := getCellTexts(cells) - entry := models.GradeEntry{ - Term: safeGet(texts, 0), - Code: safeGet(texts, 1), - Name: safeGet(texts, 2), - Nature: safeGet(texts, 3), - Category: safeGet(texts, 4), - IsExam: safeGet(texts, 6), - Credit: safeGet(texts, 7), - Score: safeGet(texts, 8), - GradePoint: safeGet(texts, 9), - } + entry := parseGradeRowTexts(texts, detectRetake(row)) if entry.Name != "" && len(entry.Name) >= 2 { grades = append(grades, entry) @@ -144,6 +118,74 @@ func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error) return summary, grades, nil } +func parseGradeRowTexts(texts []string, isRetake bool) models.GradeEntry { + entry := models.GradeEntry{IsRetake: isRetake} + + // 详细成绩/GPA 页面格式: + // 序号、学期、学院、课程代码、课程名称、课程性质、课程类别、学分、是否考试课、是否学位课、成绩标记、成绩、最终成绩、绩点1、绩点、录入时间、备注。 + if len(texts) >= 15 && looksLikeDetailedGradeRow(texts) { + entry.Term = safeGet(texts, 1) + entry.Code = safeGet(texts, 3) + entry.Name = safeGet(texts, 4) + entry.Nature = safeGet(texts, 5) + entry.Category = safeGet(texts, 6) + entry.Credit = safeGet(texts, 7) + entry.IsExam = safeGet(texts, 8) + entry.Score = firstNonEmpty(safeGet(texts, 12), safeGet(texts, 11)) + entry.GradePoint = firstNonEmpty(safeGet(texts, 14), safeGet(texts, 13)) + return entry + } + + // 常规成绩页面格式:学年、学期、课程代码、课程名称、课程性质、课程类别、学分、成绩、绩点、... + if len(texts) >= 10 { + entry.Term = safeGet(texts, 1) + entry.Code = safeGet(texts, 2) + entry.Name = safeGet(texts, 3) + entry.Nature = safeGet(texts, 4) + entry.Category = safeGet(texts, 5) + entry.Credit = safeGet(texts, 6) + entry.Score = safeGet(texts, 7) + entry.GradePoint = safeGet(texts, 8) + return entry + } + + entry.Term = safeGet(texts, 1) + entry.Code = safeGet(texts, 3) + entry.Name = safeGet(texts, 4) + entry.Credit = safeGet(texts, 7) + entry.Score = safeGet(texts, 8) + if len(texts) > 9 { + entry.GradePoint = safeGet(texts, 9) + } + return entry +} + +func looksLikeDetailedGradeRow(texts []string) bool { + return safeGet(texts, 3) != "" && safeGet(texts, 4) != "" && safeGet(texts, 7) != "" && (safeGet(texts, 8) == "是" || safeGet(texts, 8) == "否") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +// detectRetake 检测成绩行是否包含补考标记。 +// 教务系统在成绩单元格内以 补考 标记补考, +// 也会在整行文本中出现"补考"字样。 +func detectRetake(row *goquery.Selection) bool { + // 方式1:查找包含"补考"文字的 标签 + if row.Find("font:contains('补考')").Length() > 0 { + return true + } + // 方式2:整行纯文本包含"补考" + rowText := strings.TrimSpace(row.Text()) + return strings.Contains(rowText, "补考") +} + func getCellTexts(cells *goquery.Selection) []string { var texts []string cells.Each(func(_ int, cell *goquery.Selection) { diff --git a/service/grades.go b/service/grades.go index 2918a25..113992e 100644 --- a/service/grades.go +++ b/service/grades.go @@ -78,6 +78,7 @@ func convertGrades(grades []models.GradeEntry) []*pb.Grade { Score: g.Score, GradePoint: g.GradePoint, IsExam: g.IsExam, + IsRetake: g.IsRetake, }) } return result diff --git a/service/service.go b/service/service.go index 76f1a6d..0706a33 100644 --- a/service/service.go +++ b/service/service.go @@ -58,7 +58,7 @@ func (s *Service) setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) }) } -// login 执行登录流程,包含验证码识别与重试。 +// login 执行登录流程,验证码固定使用占位值 "...."。 func (s *Service) login(ctx context.Context, httpClient *http.Client, username, password string) error { log := slog.With("username", username) @@ -75,13 +75,7 @@ func (s *Service) login(ctx context.Context, httpClient *http.Client, username, 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 - } - + captchaCode := captcha.GetCaptchaCode() success, err := auth.LoginWithClient(ctx, httpClient, captchaCode, username, password) if err != nil { log.Warn("登录请求失败", "error", err, "attempt", attempt)