From 04ad865bf21f3d1e458e78e7cf9802014bbb7bbd Mon Sep 17 00:00:00 2001 From: WuYuuuub <625806558@qq.com> Date: Wed, 17 Jun 2026 22:41:52 +0800 Subject: [PATCH] =?UTF-8?q?cet=E6=88=90=E7=BB=A9=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- parser/cet.go | 184 +++++++++++++++++++++++++++++++++++++++++++++++++ service/cet.go | 66 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 parser/cet.go create mode 100644 service/cet.go diff --git a/parser/cet.go b/parser/cet.go new file mode 100644 index 0000000..fba71b9 --- /dev/null +++ b/parser/cet.go @@ -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)), " ") +} diff --git a/service/cet.go b/service/cet.go new file mode 100644 index 0000000..7a97be0 --- /dev/null +++ b/service/cet.go @@ -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 +}