This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
package captcha
|
package captcha
|
||||||
|
|
||||||
// GetCaptchaCode 返回固定验证码
|
// GetCaptchaCode 返回固定验证码占位值。
|
||||||
// 教务系统验证码已不再需要识别,直接使用固定值
|
// 教务系统验证码不再需要 AI 识别,登录时直接提交固定占位值 "...."。
|
||||||
func GetCaptchaCode() string {
|
func GetCaptchaCode() string {
|
||||||
return "...."
|
return "...."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Semester string
|
Semester string
|
||||||
TWFID string
|
TWFID string
|
||||||
Recognizer RecognizerConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecognizerConfig struct {
|
|
||||||
APIURL string
|
|
||||||
APIKey string
|
|
||||||
Model string
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -32,16 +22,6 @@ func Load() *Config {
|
|||||||
BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"),
|
BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"),
|
||||||
Semester: getEnv("JWTS_SEMESTER", ""),
|
Semester: getEnv("JWTS_SEMESTER", ""),
|
||||||
TWFID: getEnv("JWTS_TWFID", ""),
|
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
|
return cfg
|
||||||
@@ -62,22 +42,8 @@ func getEnv(key, defaultValue string) string {
|
|||||||
return val
|
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 (
|
var (
|
||||||
BaseURL = Get().BaseURL
|
BaseURL = Get().BaseURL
|
||||||
Semester = Get().Semester
|
Semester = Get().Semester
|
||||||
TWFID = Get().TWFID
|
TWFID = Get().TWFID
|
||||||
RecognizerAPIURL = Get().Recognizer.APIURL
|
|
||||||
RecognizerAPIKey = Get().Recognizer.APIKey
|
|
||||||
RecognizerModel = Get().Recognizer.Model
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,21 +2,46 @@ package grpc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"schedule_converter/service"
|
||||||
|
|
||||||
pb "schedule_converter/proto/runner"
|
pb "schedule_converter/proto/runner"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TaskHandler struct {
|
type TaskHandler struct {
|
||||||
cancelledTasks sync.Map
|
cancelledTasks sync.Map
|
||||||
|
service *service.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskHandler() *TaskHandler {
|
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 {
|
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)
|
return h.login(ctx, task.Payload)
|
||||||
case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM:
|
case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM:
|
||||||
return h.getEmptyClassrooms(ctx, task.Payload)
|
return h.getEmptyClassrooms(ctx, task.Payload)
|
||||||
|
case pb.TaskType_TASK_TYPE_GET_CET:
|
||||||
|
return h.getCET(ctx, task.Payload)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type)
|
return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type GradeEntry struct {
|
|||||||
Score string
|
Score string
|
||||||
GradePoint string
|
GradePoint string
|
||||||
IsExam string
|
IsExam string
|
||||||
|
IsRetake bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPASummary 学分绩概要
|
// GPASummary 学分绩概要
|
||||||
|
|||||||
100
parser/score.go
100
parser/score.go
@@ -39,29 +39,13 @@ func ParseGradesHTML(html string) ([]models.GradeEntry, error) {
|
|||||||
|
|
||||||
isMidterm := len(texts) == 10
|
isMidterm := len(texts) == 10
|
||||||
|
|
||||||
entry := models.GradeEntry{
|
entry := parseGradeRowTexts(texts, detectRetake(row))
|
||||||
Term: safeGet(texts, 1),
|
|
||||||
Code: safeGet(texts, 3),
|
|
||||||
Name: safeGet(texts, 4),
|
|
||||||
Credit: safeGet(texts, 7),
|
|
||||||
}
|
|
||||||
|
|
||||||
if isMidterm {
|
if isMidterm && entry.Score == "" {
|
||||||
entry.Score = safeGet(texts, 8)
|
entry.Score = safeGet(texts, 8)
|
||||||
if len(texts) > 9 {
|
if len(texts) > 9 {
|
||||||
entry.GradePoint = safeGet(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 {
|
if entry.Name != "" && len(entry.Name) >= 2 {
|
||||||
@@ -123,17 +107,7 @@ func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error)
|
|||||||
|
|
||||||
texts := getCellTexts(cells)
|
texts := getCellTexts(cells)
|
||||||
|
|
||||||
entry := models.GradeEntry{
|
entry := parseGradeRowTexts(texts, detectRetake(row))
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.Name != "" && len(entry.Name) >= 2 {
|
if entry.Name != "" && len(entry.Name) >= 2 {
|
||||||
grades = append(grades, entry)
|
grades = append(grades, entry)
|
||||||
@@ -144,6 +118,74 @@ func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error)
|
|||||||
return summary, grades, nil
|
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 {
|
func getCellTexts(cells *goquery.Selection) []string {
|
||||||
var texts []string
|
var texts []string
|
||||||
cells.Each(func(_ int, cell *goquery.Selection) {
|
cells.Each(func(_ int, cell *goquery.Selection) {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ func convertGrades(grades []models.GradeEntry) []*pb.Grade {
|
|||||||
Score: g.Score,
|
Score: g.Score,
|
||||||
GradePoint: g.GradePoint,
|
GradePoint: g.GradePoint,
|
||||||
IsExam: g.IsExam,
|
IsExam: g.IsExam,
|
||||||
|
IsRetake: g.IsRetake,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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 {
|
func (s *Service) login(ctx context.Context, httpClient *http.Client, username, password string) error {
|
||||||
log := slog.With("username", username)
|
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)
|
log.Info("尝试登录", "attempt", attempt, "max_retries", maxLoginRetries)
|
||||||
|
|
||||||
captchaCode, err := captcha.GetAndRecognize(httpClient)
|
captchaCode := captcha.GetCaptchaCode()
|
||||||
if err != nil {
|
|
||||||
log.Warn("验证码获取失败", "error", err, "attempt", attempt)
|
|
||||||
time.Sleep(loginRetryInterval)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
success, err := auth.LoginWithClient(ctx, httpClient, captchaCode, username, password)
|
success, err := auth.LoginWithClient(ctx, httpClient, captchaCode, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("登录请求失败", "error", err, "attempt", attempt)
|
log.Warn("登录请求失败", "error", err, "attempt", attempt)
|
||||||
|
|||||||
Reference in New Issue
Block a user