删除了ai识别验证码部分,新增个人成绩补考标识和cet成绩查询
Some checks failed
Build / build (push) Failing after 2m13s

This commit is contained in:
WuYuuuub
2026-06-17 14:48:27 +08:00
parent c4c3320f51
commit 4024f9eae0
9 changed files with 110 additions and 79 deletions

View File

@@ -1,7 +1,7 @@
package captcha
// GetCaptchaCode 返回固定验证码
// 教务系统验证码不再需要识别,直接使用固定值
// GetCaptchaCode 返回固定验证码占位值。
// 教务系统验证码不再需要 AI 识别,登录时直接提交固定占位值 "...."。
func GetCaptchaCode() string {
return "...."
}

View File

@@ -49,4 +49,4 @@ func CheckLoginSuccess(body string) bool {
func IsCaptchaError(body string) bool {
return strings.Contains(body, "验证码")
}
}

View File

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

View File

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

View File

@@ -124,4 +124,4 @@ func runGRPCClient(serverAddr string) {
}
time.Sleep(initialReconnectInterval)
}
}
}

View File

@@ -19,6 +19,7 @@ type GradeEntry struct {
Score string
GradePoint string
IsExam string
IsRetake bool
}
// GPASummary 学分绩概要

View File

@@ -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 检测成绩行是否包含补考标记。
// 教务系统在成绩单元格内以 <font color="#0066ff">补考</font> 标记补考,
// 也会在整行文本中出现"补考"字样。
func detectRetake(row *goquery.Selection) bool {
// 方式1查找包含"补考"文字的 <font> 标签
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) {

View File

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

View File

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