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)