Compare commits

..

5 Commits

Author SHA1 Message Date
WuYuuuub
04ad865bf2 cet成绩查询
All checks were successful
Build / build (push) Successful in 9m51s
2026-06-17 22:41:52 +08:00
WuYuuuub
8915fca841 cet成绩查询handler
Some checks failed
Build / build (push) Has been cancelled
2026-06-17 22:41:34 +08:00
WuYuuuub
4024f9eae0 删除了ai识别验证码部分,新增个人成绩补考标识和cet成绩查询
Some checks failed
Build / build (push) Failing after 2m13s
2026-06-17 14:48:27 +08:00
WuYuuuub
c4c3320f51 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	config/config.go
#	grpc/convert.go
#	grpc/handler.go
#	parser/score.go
#	service/service.go
2026-06-17 13:31:12 +08:00
WuYuuuub
58301bbed0 删除了ai识别验证码部分,新增个人成绩补考标识和cet成绩查询 2026-06-17 12:22:16 +08:00
15 changed files with 776 additions and 209 deletions

View File

@@ -1,55 +1,7 @@
package captcha
import (
"fmt"
"io"
"log/slog"
"net/http"
"schedule_converter/client"
"schedule_converter/config"
)
func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
req, err := http.NewRequest("GET", config.Get().BaseURL+"/captchaImage", 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)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
func GetAndRecognize(httpClient *http.Client) (string, error) {
_, err := SaveCaptchaImage(httpClient)
if err != nil {
return "", err
}
captchaCode, err := Recognize("captcha.png")
if err != nil {
return "", err
}
slog.Debug("验证码识别完成", "result", captchaCode)
return captchaCode, nil
}
func Recognize(imagePath string) (string, error) {
return "....", nil
// GetCaptchaCode 返回固定验证码占位值。
// 教务系统验证码不再需要 AI 识别,登录时直接提交固定占位值 "...."。
func GetCaptchaCode() string {
return "...."
}

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

@@ -15,17 +15,35 @@ import (
)
type TaskHandler struct {
service *service.Service
cancels sync.Map // taskID -> context.CancelFunc运行中的任务
preCancelled sync.Map // taskID -> struct{}(任务开始前就已取消)
cancelledTasks sync.Map
service *service.Service
}
func NewTaskHandler() *TaskHandler {
return &TaskHandler{
service: service.NewService(),
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 {
log := slog.With("task_id", task.TaskId, "task_type", task.Type.String())
log.Info("开始执行任务")
@@ -35,17 +53,14 @@ func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult {
CompletedAt: time.Now().UnixMilli(),
}
// 任务开始前检查是否已被预取消。
if _, cancelled := h.preCancelled.LoadAndDelete(task.TaskId); cancelled {
if h.IsCancelled(task.TaskId) {
result.Status = pb.TaskStatus_TASK_STATUS_CANCELLED
result.ErrorMessage = "任务已取消"
log.Info("任务已取消")
return result
}
ctx, cancel := h.buildTaskContext(task)
defer cancel()
ctx := context.Background()
data, err := h.dispatch(ctx, task)
if err != nil {
@@ -67,68 +82,33 @@ func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult {
return result
}
// buildTaskContext 构造任务执行 context根据 TimeoutSeconds 设置截止时间,
// 并注册 cancel 函数,使 CancelTask 能够中断正在执行的任务。
func (h *TaskHandler) buildTaskContext(task *pb.Task) (context.Context, context.CancelFunc) {
ctx := context.Background()
if task.TimeoutSeconds > 0 {
ctx, cancel := context.WithTimeout(ctx, time.Duration(task.TimeoutSeconds)*time.Second)
h.cancels.Store(task.TaskId, context.CancelFunc(cancel))
return ctx, func() {
h.cancels.Delete(task.TaskId)
cancel()
}
}
ctx, cancel := context.WithCancel(ctx)
h.cancels.Store(task.TaskId, context.CancelFunc(cancel))
return ctx, func() {
h.cancels.Delete(task.TaskId)
cancel()
}
}
var errUnknownTaskType = fmt.Errorf("unknown task type")
// taskHandlers 将任务类型映射到处理方法。
var taskHandlers = map[pb.TaskType]func(h *TaskHandler, ctx context.Context, payload []byte) ([]byte, error){
pb.TaskType_TASK_TYPE_GET_SCHEDULE: (*TaskHandler).getSchedule,
pb.TaskType_TASK_TYPE_GET_GRADES: (*TaskHandler).getGrades,
pb.TaskType_TASK_TYPE_GET_EXAMS: (*TaskHandler).getExams,
pb.TaskType_TASK_TYPE_LOGIN: (*TaskHandler).login,
pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM: (*TaskHandler).getEmptyClassrooms,
}
// supportedTaskTypes 返回当前支持的任务类型列表,供注册 capabilities 使用。
func supportedTaskTypes() []pb.TaskType {
types := make([]pb.TaskType, 0, len(taskHandlers))
for t := range taskHandlers {
types = append(types, t)
}
return types
}
func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, error) {
handler, ok := taskHandlers[task.Type]
if !ok {
switch task.Type {
case pb.TaskType_TASK_TYPE_GET_SCHEDULE:
return h.getSchedule(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_GET_GRADES:
return h.getGrades(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_GET_EXAMS:
return h.getExams(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_LOGIN:
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)
}
return handler(h, ctx, task.Payload)
}
// CancelTask 取消任务:若任务正在执行则中断其 context若尚未开始则记为预取消。
func (h *TaskHandler) CancelTask(taskID string) {
if v, ok := h.cancels.LoadAndDelete(taskID); ok {
if cancel, ok := v.(context.CancelFunc); ok {
cancel()
}
return
}
// 任务尚未开始执行,标记为预取消,待 Execute 时直接返回取消。
h.preCancelled.Store(taskID, struct{}{})
h.cancelledTasks.Store(taskID, true)
slog.Info("任务已标记为取消", "task_id", taskID)
}
// unmarshalPayload 解码任务载荷。
func unmarshalPayload(payload []byte, v any) error {
return json.Unmarshal(payload, v)
func (h *TaskHandler) IsCancelled(taskID string) bool {
v, ok := h.cancelledTasks.Load(taskID)
return ok && v.(bool)
}

29
grpc/handler_cet.go Normal file
View File

@@ -0,0 +1,29 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
apperr "schedule_converter/pkg/errors"
)
func (h *TaskHandler) getCET(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetCetPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, err
}
log := slog.With("username", req.Username)
log.Info("获取四六级成绩任务")
result, err := h.service.GetCET(ctx, &req)
if err != nil {
return nil, apperr.New("get_cet", err, "获取四六级成绩失败")
}
log.Info("四六级成绩获取成功", "score_count", len(result.Scores))
return json.Marshal(result)
}

View File

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

184
parser/cet.go Normal file
View File

@@ -0,0 +1,184 @@
package parser
import (
"fmt"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
)
// CETScore 四六级成绩条目。
type CETScore struct {
Level string // 等级:英语四级/英语六级
ExamTime string // 考试时间:如 2025年06月
TicketNum string // 准考证号
Score string // 成绩
}
// CETStudentInfo 四六级查询中的学生信息。
type CETStudentInfo struct {
StudentID string // 学号
Name string // 姓名
Gender string // 性别
IDCard string // 身份证号
Duration string // 学制
Grade string // 年级
College string // 学院
Major string // 专业
}
// CETResult 四六级查询结果。
type CETResult struct {
StudentInfo CETStudentInfo
Scores []CETScore
}
// cetScoreRegex 匹配四六级成绩行。
// 示例:英语六级 时间 2025年06月 准考证号 372220251213105 成绩 445
var cetScoreRegex = regexp.MustCompile(`(英语[四六]级)\s+时间\s*([0-9]{4}年[0-9]{1,2}月)\s+准考证号\s*([0-9A-Za-z]+)\s+成绩\s*([0-9]+|缺考|合格|不合格|优秀|良好|通过|未通过)`)
// FetchCETHTML 解析四六级成绩 HTML 页面。
func ParseCETHTML(html string) (*CETResult, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, err
}
result := &CETResult{}
table := findCETTable(doc)
if table.Length() == 0 {
return nil, fmt.Errorf("未找到四六级成绩表格")
}
parseCETStudentInfo(table, &result.StudentInfo)
result.Scores = parseCETScores(table)
if len(result.Scores) == 0 {
result.Scores = parseCETScoresFromText(doc.Find(".jdcx_area").Text())
}
if len(result.Scores) == 0 {
result.Scores = parseCETScoresFromText(doc.Find(".Contentbox").Text())
}
if len(result.Scores) == 0 {
result.Scores = parseCETScoresFromText(doc.Text())
}
return result, nil
}
func findCETTable(doc *goquery.Document) *goquery.Selection {
selectors := []string{
"table.addlist",
".jdcx_area table",
"form#ff table",
"table:contains('已有成绩')",
}
for _, selector := range selectors {
table := doc.Find(selector).First()
if table.Length() > 0 {
return table
}
}
return doc.Find("__not_found__")
}
func parseCETStudentInfo(table *goquery.Selection, info *CETStudentInfo) {
table.Find("tr").Each(func(_ int, row *goquery.Selection) {
var label string
row.Find("th, td").Each(func(_ int, cell *goquery.Selection) {
text := normalizeCETText(cell.Text())
if text == "" {
return
}
if cell.Is("th") {
label = normalizeCETLabel(text)
return
}
assignCETStudentField(info, label, text)
label = ""
})
})
}
func assignCETStudentField(info *CETStudentInfo, label, value string) {
switch label {
case "学号":
info.StudentID = value
case "姓名":
info.Name = value
case "性别":
info.Gender = value
case "身份证号":
info.IDCard = value
case "学制":
info.Duration = value
case "年级":
info.Grade = value
case "学院":
info.College = value
case "专业":
info.Major = value
}
}
func parseCETScores(table *goquery.Selection) []CETScore {
var scores []CETScore
table.Find("tr").Each(func(_ int, row *goquery.Selection) {
rowText := normalizeCETText(row.Text())
if !strings.Contains(rowText, "英语四级") && !strings.Contains(rowText, "英语六级") {
return
}
scores = append(scores, parseCETScoresFromText(rowText)...)
})
return deduplicateCETScores(scores)
}
func parseCETScoresFromText(text string) []CETScore {
text = normalizeCETText(text)
matches := cetScoreRegex.FindAllStringSubmatch(text, -1)
scores := make([]CETScore, 0, len(matches))
for _, match := range matches {
if len(match) < 5 {
continue
}
scores = append(scores, CETScore{
Level: strings.TrimSpace(match[1]),
ExamTime: strings.TrimSpace(match[2]),
TicketNum: strings.TrimSpace(match[3]),
Score: strings.TrimSpace(match[4]),
})
}
return deduplicateCETScores(scores)
}
func deduplicateCETScores(scores []CETScore) []CETScore {
if len(scores) <= 1 {
return scores
}
seen := make(map[string]struct{}, len(scores))
result := make([]CETScore, 0, len(scores))
for _, score := range scores {
key := strings.Join([]string{score.Level, score.ExamTime, score.TicketNum, score.Score}, "|")
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, score)
}
return result
}
func normalizeCETLabel(label string) string {
label = normalizeCETText(label)
label = strings.ReplaceAll(label, " ", "")
label = strings.ReplaceAll(label, "", "")
label = strings.ReplaceAll(label, ":", "")
return label
}
func normalizeCETText(text string) string {
text = strings.ReplaceAll(text, "\u00a0", " ")
return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
}

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

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.1
// protoc v6.32.0
// source: proto/runner/runner.proto
package runner
@@ -32,6 +32,7 @@ const (
TaskType_TASK_TYPE_GET_EXAMS TaskType = 4 // 获取考试安排
TaskType_TASK_TYPE_GET_USER_INFO TaskType = 5 // 获取用户信息
TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM TaskType = 6 // 获取空教室
TaskType_TASK_TYPE_GET_CET TaskType = 7 // 获取四六级成绩
)
// Enum value maps for TaskType.
@@ -44,6 +45,7 @@ var (
4: "TASK_TYPE_GET_EXAMS",
5: "TASK_TYPE_GET_USER_INFO",
6: "TASK_TYPE_GET_EMPTY_CLASSROOM",
7: "TASK_TYPE_GET_CET",
}
TaskType_value = map[string]int32{
"TASK_TYPE_UNKNOWN": 0,
@@ -53,6 +55,7 @@ var (
"TASK_TYPE_GET_EXAMS": 4,
"TASK_TYPE_GET_USER_INFO": 5,
"TASK_TYPE_GET_EMPTY_CLASSROOM": 6,
"TASK_TYPE_GET_CET": 7,
}
)
@@ -1378,6 +1381,7 @@ type Grade struct {
Score string `protobuf:"bytes,7,opt,name=score,proto3" json:"score,omitempty"` // 成绩 (可能是等级或分数)
GradePoint string `protobuf:"bytes,8,opt,name=grade_point,json=gradePoint,proto3" json:"grade_point,omitempty"` // 绩点
IsExam string `protobuf:"bytes,9,opt,name=is_exam,json=isExam,proto3" json:"is_exam,omitempty"` // 是否考试课
IsRetake bool `protobuf:"varint,10,opt,name=is_retake,json=isRetake,proto3" json:"is_retake,omitempty"` // 是否为补考
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1475,6 +1479,13 @@ func (x *Grade) GetIsExam() string {
return ""
}
func (x *Grade) GetIsRetake() bool {
if x != nil {
return x.IsRetake
}
return false
}
// ExamsResultData 考试安排结果数据
type ExamsResultData struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1945,6 +1956,282 @@ func (x *EmptyClassroomEntry) GetWeeks() string {
return ""
}
// GetCetPayload 获取四六级成绩任务载荷
type GetCetPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` // 学号
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` // 密码
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetCetPayload) Reset() {
*x = GetCetPayload{}
mi := &file_proto_runner_runner_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetCetPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetCetPayload) ProtoMessage() {}
func (x *GetCetPayload) ProtoReflect() protoreflect.Message {
mi := &file_proto_runner_runner_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetCetPayload.ProtoReflect.Descriptor instead.
func (*GetCetPayload) Descriptor() ([]byte, []int) {
return file_proto_runner_runner_proto_rawDescGZIP(), []int{24}
}
func (x *GetCetPayload) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *GetCetPayload) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
// CetScore 四六级成绩条目
type CetScore struct {
state protoimpl.MessageState `protogen:"open.v1"`
Level string `protobuf:"bytes,1,opt,name=level,proto3" json:"level,omitempty"` // 等级(英语四级/英语六级)
ExamTime string `protobuf:"bytes,2,opt,name=exam_time,json=examTime,proto3" json:"exam_time,omitempty"` // 考试时间,如 2025年06月
TicketNum string `protobuf:"bytes,3,opt,name=ticket_num,json=ticketNum,proto3" json:"ticket_num,omitempty"` // 准考证号
Score string `protobuf:"bytes,4,opt,name=score,proto3" json:"score,omitempty"` // 成绩
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CetScore) Reset() {
*x = CetScore{}
mi := &file_proto_runner_runner_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CetScore) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CetScore) ProtoMessage() {}
func (x *CetScore) ProtoReflect() protoreflect.Message {
mi := &file_proto_runner_runner_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CetScore.ProtoReflect.Descriptor instead.
func (*CetScore) Descriptor() ([]byte, []int) {
return file_proto_runner_runner_proto_rawDescGZIP(), []int{25}
}
func (x *CetScore) GetLevel() string {
if x != nil {
return x.Level
}
return ""
}
func (x *CetScore) GetExamTime() string {
if x != nil {
return x.ExamTime
}
return ""
}
func (x *CetScore) GetTicketNum() string {
if x != nil {
return x.TicketNum
}
return ""
}
func (x *CetScore) GetScore() string {
if x != nil {
return x.Score
}
return ""
}
// CetStudentInfo 四六级查询页面的学生信息
type CetStudentInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
StudentId string `protobuf:"bytes,1,opt,name=student_id,json=studentId,proto3" json:"student_id,omitempty"` // 学号
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // 姓名
Gender string `protobuf:"bytes,3,opt,name=gender,proto3" json:"gender,omitempty"` // 性别
IdCard string `protobuf:"bytes,4,opt,name=id_card,json=idCard,proto3" json:"id_card,omitempty"` // 身份证号
Duration string `protobuf:"bytes,5,opt,name=duration,proto3" json:"duration,omitempty"` // 学制
Grade string `protobuf:"bytes,6,opt,name=grade,proto3" json:"grade,omitempty"` // 年级
College string `protobuf:"bytes,7,opt,name=college,proto3" json:"college,omitempty"` // 学院
Major string `protobuf:"bytes,8,opt,name=major,proto3" json:"major,omitempty"` // 专业
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CetStudentInfo) Reset() {
*x = CetStudentInfo{}
mi := &file_proto_runner_runner_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CetStudentInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CetStudentInfo) ProtoMessage() {}
func (x *CetStudentInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_runner_runner_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CetStudentInfo.ProtoReflect.Descriptor instead.
func (*CetStudentInfo) Descriptor() ([]byte, []int) {
return file_proto_runner_runner_proto_rawDescGZIP(), []int{26}
}
func (x *CetStudentInfo) GetStudentId() string {
if x != nil {
return x.StudentId
}
return ""
}
func (x *CetStudentInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *CetStudentInfo) GetGender() string {
if x != nil {
return x.Gender
}
return ""
}
func (x *CetStudentInfo) GetIdCard() string {
if x != nil {
return x.IdCard
}
return ""
}
func (x *CetStudentInfo) GetDuration() string {
if x != nil {
return x.Duration
}
return ""
}
func (x *CetStudentInfo) GetGrade() string {
if x != nil {
return x.Grade
}
return ""
}
func (x *CetStudentInfo) GetCollege() string {
if x != nil {
return x.College
}
return ""
}
func (x *CetStudentInfo) GetMajor() string {
if x != nil {
return x.Major
}
return ""
}
// CetResultData 四六级成绩结果数据
type CetResultData struct {
state protoimpl.MessageState `protogen:"open.v1"`
StudentInfo *CetStudentInfo `protobuf:"bytes,1,opt,name=student_info,json=studentInfo,proto3" json:"student_info,omitempty"` // 学生信息
Scores []*CetScore `protobuf:"bytes,2,rep,name=scores,proto3" json:"scores,omitempty"` // 成绩列表
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CetResultData) Reset() {
*x = CetResultData{}
mi := &file_proto_runner_runner_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CetResultData) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CetResultData) ProtoMessage() {}
func (x *CetResultData) ProtoReflect() protoreflect.Message {
mi := &file_proto_runner_runner_proto_msgTypes[27]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CetResultData.ProtoReflect.Descriptor instead.
func (*CetResultData) Descriptor() ([]byte, []int) {
return file_proto_runner_runner_proto_rawDescGZIP(), []int{27}
}
func (x *CetResultData) GetStudentInfo() *CetStudentInfo {
if x != nil {
return x.StudentInfo
}
return nil
}
func (x *CetResultData) GetScores() []*CetScore {
if x != nil {
return x.Scores
}
return nil
}
var File_proto_runner_runner_proto protoreflect.FileDescriptor
const file_proto_runner_runner_proto_rawDesc = "" +
@@ -2047,7 +2334,7 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"student_id\x18\x02 \x01(\tR\tstudentId\x12%\n" +
"\x06grades\x18\x03 \x03(\v2\r.runner.GradeR\x06grades\x123\n" +
"\vgpa_summary\x18\x04 \x01(\v2\x12.runner.GpaSummaryR\n" +
"gpaSummary\"\xf9\x01\n" +
"gpaSummary\"\x96\x02\n" +
"\x05Grade\x12\x12\n" +
"\x04term\x18\x01 \x01(\tR\x04term\x12\x1f\n" +
"\vcourse_code\x18\x02 \x01(\tR\n" +
@@ -2060,7 +2347,9 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"\x05score\x18\a \x01(\tR\x05score\x12\x1f\n" +
"\vgrade_point\x18\b \x01(\tR\n" +
"gradePoint\x12\x17\n" +
"\ais_exam\x18\t \x01(\tR\x06isExam\"p\n" +
"\ais_exam\x18\t \x01(\tR\x06isExam\x12\x1b\n" +
"\tis_retake\x18\n" +
" \x01(\bR\bisRetake\"p\n" +
"\x0fExamsResultData\x12\x1a\n" +
"\bsemester\x18\x01 \x01(\tR\bsemester\x12\x1d\n" +
"\n" +
@@ -2106,7 +2395,29 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"\aweekday\x18\x02 \x01(\tR\aweekday\x12\x18\n" +
"\aperiods\x18\x03 \x01(\tR\aperiods\x12\x12\n" +
"\x04term\x18\x04 \x01(\tR\x04term\x12\x14\n" +
"\x05weeks\x18\x05 \x01(\tR\x05weeks*\xc5\x01\n" +
"\x05weeks\x18\x05 \x01(\tR\x05weeks\"G\n" +
"\rGetCetPayload\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\"r\n" +
"\bCetScore\x12\x14\n" +
"\x05level\x18\x01 \x01(\tR\x05level\x12\x1b\n" +
"\texam_time\x18\x02 \x01(\tR\bexamTime\x12\x1d\n" +
"\n" +
"ticket_num\x18\x03 \x01(\tR\tticketNum\x12\x14\n" +
"\x05score\x18\x04 \x01(\tR\x05score\"\xd6\x01\n" +
"\x0eCetStudentInfo\x12\x1d\n" +
"\n" +
"student_id\x18\x01 \x01(\tR\tstudentId\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
"\x06gender\x18\x03 \x01(\tR\x06gender\x12\x17\n" +
"\aid_card\x18\x04 \x01(\tR\x06idCard\x12\x1a\n" +
"\bduration\x18\x05 \x01(\tR\bduration\x12\x14\n" +
"\x05grade\x18\x06 \x01(\tR\x05grade\x12\x18\n" +
"\acollege\x18\a \x01(\tR\acollege\x12\x14\n" +
"\x05major\x18\b \x01(\tR\x05major\"t\n" +
"\rCetResultData\x129\n" +
"\fstudent_info\x18\x01 \x01(\v2\x16.runner.CetStudentInfoR\vstudentInfo\x12(\n" +
"\x06scores\x18\x02 \x03(\v2\x10.runner.CetScoreR\x06scores*\xdc\x01\n" +
"\bTaskType\x12\x15\n" +
"\x11TASK_TYPE_UNKNOWN\x10\x00\x12\x13\n" +
"\x0fTASK_TYPE_LOGIN\x10\x01\x12\x1a\n" +
@@ -2114,7 +2425,8 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"\x14TASK_TYPE_GET_GRADES\x10\x03\x12\x17\n" +
"\x13TASK_TYPE_GET_EXAMS\x10\x04\x12\x1b\n" +
"\x17TASK_TYPE_GET_USER_INFO\x10\x05\x12!\n" +
"\x1dTASK_TYPE_GET_EMPTY_CLASSROOM\x10\x06*\x8a\x01\n" +
"\x1dTASK_TYPE_GET_EMPTY_CLASSROOM\x10\x06\x12\x15\n" +
"\x11TASK_TYPE_GET_CET\x10\a*\x8a\x01\n" +
"\n" +
"TaskStatus\x12\x17\n" +
"\x13TASK_STATUS_UNKNOWN\x10\x00\x12\x17\n" +
@@ -2138,7 +2450,7 @@ func file_proto_runner_runner_proto_rawDescGZIP() []byte {
}
var file_proto_runner_runner_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_proto_runner_runner_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
var file_proto_runner_runner_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
var file_proto_runner_runner_proto_goTypes = []any{
(TaskType)(0), // 0: runner.TaskType
(TaskStatus)(0), // 1: runner.TaskStatus
@@ -2166,7 +2478,11 @@ var file_proto_runner_runner_proto_goTypes = []any{
(*GetEmptyClassroomPayload)(nil), // 23: runner.GetEmptyClassroomPayload
(*EmptyClassroomResultData)(nil), // 24: runner.EmptyClassroomResultData
(*EmptyClassroomEntry)(nil), // 25: runner.EmptyClassroomEntry
nil, // 26: runner.RegisterRequest.CapabilitiesEntry
(*GetCetPayload)(nil), // 26: runner.GetCetPayload
(*CetScore)(nil), // 27: runner.CetScore
(*CetStudentInfo)(nil), // 28: runner.CetStudentInfo
(*CetResultData)(nil), // 29: runner.CetResultData
nil, // 30: runner.RegisterRequest.CapabilitiesEntry
}
var file_proto_runner_runner_proto_depIdxs = []int32{
3, // 0: runner.StreamMessage.register:type_name -> runner.RegisterRequest
@@ -2176,7 +2492,7 @@ var file_proto_runner_runner_proto_depIdxs = []int32{
7, // 4: runner.StreamMessage.task:type_name -> runner.Task
9, // 5: runner.StreamMessage.result:type_name -> runner.TaskResult
8, // 6: runner.StreamMessage.task_cancel:type_name -> runner.TaskCancel
26, // 7: runner.RegisterRequest.capabilities:type_name -> runner.RegisterRequest.CapabilitiesEntry
30, // 7: runner.RegisterRequest.capabilities:type_name -> runner.RegisterRequest.CapabilitiesEntry
0, // 8: runner.Task.type:type_name -> runner.TaskType
1, // 9: runner.TaskResult.status:type_name -> runner.TaskStatus
15, // 10: runner.ScheduleResultData.courses:type_name -> runner.Course
@@ -2185,13 +2501,15 @@ var file_proto_runner_runner_proto_depIdxs = []int32{
17, // 13: runner.GradesResultData.gpa_summary:type_name -> runner.GpaSummary
21, // 14: runner.ExamsResultData.exams:type_name -> runner.Exam
25, // 15: runner.EmptyClassroomResultData.classrooms:type_name -> runner.EmptyClassroomEntry
2, // 16: runner.RunnerHub.Connect:input_type -> runner.StreamMessage
2, // 17: runner.RunnerHub.Connect:output_type -> runner.StreamMessage
17, // [17:18] is the sub-list for method output_type
16, // [16:17] is the sub-list for method input_type
16, // [16:16] is the sub-list for extension type_name
16, // [16:16] is the sub-list for extension extendee
0, // [0:16] is the sub-list for field type_name
28, // 16: runner.CetResultData.student_info:type_name -> runner.CetStudentInfo
27, // 17: runner.CetResultData.scores:type_name -> runner.CetScore
2, // 18: runner.RunnerHub.Connect:input_type -> runner.StreamMessage
2, // 19: runner.RunnerHub.Connect:output_type -> runner.StreamMessage
19, // [19:20] is the sub-list for method output_type
18, // [18:19] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
}
func init() { file_proto_runner_runner_proto_init() }
@@ -2214,7 +2532,7 @@ func file_proto_runner_runner_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_runner_runner_proto_rawDesc), len(file_proto_runner_runner_proto_rawDesc)),
NumEnums: 2,
NumMessages: 25,
NumMessages: 29,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -82,6 +82,7 @@ enum TaskType {
TASK_TYPE_GET_EXAMS = 4; // 获取考试安排
TASK_TYPE_GET_USER_INFO = 5; // 获取用户信息
TASK_TYPE_GET_EMPTY_CLASSROOM = 6; // 获取空教室
TASK_TYPE_GET_CET = 7; // 获取四六级成绩
}
// TaskStatus 任务状态枚举
@@ -208,6 +209,7 @@ message Grade {
string score = 7; // 成绩 (可能是等级或分数)
string grade_point = 8; // 绩点
string is_exam = 9; // 是否考试课
bool is_retake = 10; // 是否为补考
}
// ExamsResultData 考试安排结果数据
@@ -264,3 +266,35 @@ message EmptyClassroomEntry {
string term = 4; // 学期描述
string weeks = 5; // 周次范围
}
// GetCetPayload 获取四六级成绩任务载荷
message GetCetPayload {
string username = 1; // 学号
string password = 2; // 密码
}
// CetScore 四六级成绩条目
message CetScore {
string level = 1; // 等级(英语四级/英语六级)
string exam_time = 2; // 考试时间,如 2025年06月
string ticket_num = 3; // 准考证号
string score = 4; // 成绩
}
// CetStudentInfo 四六级查询页面的学生信息
message CetStudentInfo {
string student_id = 1; // 学号
string name = 2; // 姓名
string gender = 3; // 性别
string id_card = 4; // 身份证号
string duration = 5; // 学制
string grade = 6; // 年级
string college = 7; // 学院
string major = 8; // 专业
}
// CetResultData 四六级成绩结果数据
message CetResultData {
CetStudentInfo student_info = 1; // 学生信息
repeated CetScore scores = 2; // 成绩列表
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.0
// source: proto/runner/runner.proto
package runner

66
service/cet.go Normal file
View File

@@ -0,0 +1,66 @@
package service
import (
"context"
"log/slog"
"schedule_converter/client"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
const cetQueryPath = "/sljcjcx/sljcjcxInit"
// GetCET 获取四六级成绩:登录 → 请求四六级查询结果页 → 解析 → 转换为 pb 结果。
func (s *Service) GetCET(ctx context.Context, req *pb.GetCetPayload) (*pb.CetResultData, error) {
log := slog.With("username", req.Username)
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
html, err := client.FetchHTML(ctx, httpClient, "GET", cetQueryPath, nil, "", "")
if err != nil {
return nil, apperr.New("fetch_cet", err, "获取四六级成绩页面失败")
}
cetResult, err := parser.ParseCETHTML(html)
if err != nil {
return nil, apperr.New("parse_cet", err, "解析四六级成绩失败")
}
log.Info("四六级成绩获取成功", "score_count", len(cetResult.Scores))
return &pb.CetResultData{
StudentInfo: convertCETStudentInfo(cetResult.StudentInfo),
Scores: convertCETScores(cetResult.Scores),
}, nil
}
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 _, score := range scores {
result = append(result, &pb.CetScore{
Level: score.Level,
ExamTime: score.ExamTime,
TicketNum: score.TicketNum,
Score: score.Score,
})
}
return result
}

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)