2026-05-11 00:46:38 +08:00
|
|
|
|
package parser
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
2026-06-16 18:33:13 +08:00
|
|
|
|
"schedule_converter/models"
|
2026-05-11 00:46:38 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-16 18:33:13 +08:00
|
|
|
|
// ParseGradesHTML 解析成绩页面 HTML,返回成绩条目列表。
|
|
|
|
|
|
func ParseGradesHTML(html string) ([]models.GradeEntry, error) {
|
2026-05-11 00:46:38 +08:00
|
|
|
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 18:33:13 +08:00
|
|
|
|
var grades []models.GradeEntry
|
2026-05-11 00:46:38 +08:00
|
|
|
|
|
|
|
|
|
|
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
|
|
|
|
|
|
rows := table.Find("tr")
|
|
|
|
|
|
if rows.Length() < 2 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
rows.Each(func(idx int, row *goquery.Selection) {
|
|
|
|
|
|
if idx == 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
cells := row.Find("td")
|
|
|
|
|
|
if cells.Length() < 8 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
texts := getCellTexts(cells)
|
|
|
|
|
|
if len(texts) < 9 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isMidterm := len(texts) == 10
|
|
|
|
|
|
|
2026-06-17 14:48:27 +08:00
|
|
|
|
entry := parseGradeRowTexts(texts, detectRetake(row))
|
2026-05-11 00:46:38 +08:00
|
|
|
|
|
2026-06-17 14:48:27 +08:00
|
|
|
|
if isMidterm && entry.Score == "" {
|
2026-05-11 00:46:38 +08:00
|
|
|
|
entry.Score = safeGet(texts, 8)
|
|
|
|
|
|
if len(texts) > 9 {
|
|
|
|
|
|
entry.GradePoint = safeGet(texts, 9)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if entry.Name != "" && len(entry.Name) >= 2 {
|
|
|
|
|
|
if entry.Score == "" {
|
|
|
|
|
|
entry.Score = "-"
|
|
|
|
|
|
}
|
|
|
|
|
|
grades = append(grades, entry)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return grades, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 18:33:13 +08:00
|
|
|
|
// ParseGPAHTML 解析学分绩页面 HTML,返回 GPA 概要与成绩条目列表。
|
|
|
|
|
|
func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error) {
|
2026-05-11 00:46:38 +08:00
|
|
|
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 18:33:13 +08:00
|
|
|
|
summary := &models.GPASummary{}
|
2026-05-11 00:46:38 +08:00
|
|
|
|
|
|
|
|
|
|
doc.Find("span").Each(func(_ int, span *goquery.Selection) {
|
|
|
|
|
|
id, _ := span.Attr("id")
|
|
|
|
|
|
text := strings.TrimSpace(span.Text())
|
|
|
|
|
|
switch id {
|
|
|
|
|
|
case "pjxfj":
|
|
|
|
|
|
summary.AverageGPA = text
|
|
|
|
|
|
case "zrs":
|
|
|
|
|
|
summary.Rank = text
|
|
|
|
|
|
case "kskxfj":
|
|
|
|
|
|
summary.ExamTotalGPA = text
|
|
|
|
|
|
case "kskxfs":
|
|
|
|
|
|
summary.ExamTotalCredits = text
|
|
|
|
|
|
case "kckxfj":
|
|
|
|
|
|
summary.FailCredits = text
|
|
|
|
|
|
case "xqs":
|
|
|
|
|
|
summary.Semesters = text
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-16 18:33:13 +08:00
|
|
|
|
var grades []models.GradeEntry
|
2026-05-11 00:46:38 +08:00
|
|
|
|
|
|
|
|
|
|
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
|
|
|
|
|
|
rows := table.Find("tr")
|
|
|
|
|
|
if rows.Length() < 2 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
rows.Each(func(idx int, row *goquery.Selection) {
|
|
|
|
|
|
if idx == 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
cells := row.Find("td")
|
|
|
|
|
|
if cells.Length() < 10 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
texts := getCellTexts(cells)
|
|
|
|
|
|
|
2026-06-17 14:48:27 +08:00
|
|
|
|
entry := parseGradeRowTexts(texts, detectRetake(row))
|
2026-05-11 00:46:38 +08:00
|
|
|
|
|
|
|
|
|
|
if entry.Name != "" && len(entry.Name) >= 2 {
|
|
|
|
|
|
grades = append(grades, entry)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return summary, grades, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 14:48:27 +08:00
|
|
|
|
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, "补考")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 00:46:38 +08:00
|
|
|
|
func getCellTexts(cells *goquery.Selection) []string {
|
|
|
|
|
|
var texts []string
|
|
|
|
|
|
cells.Each(func(_ int, cell *goquery.Selection) {
|
|
|
|
|
|
text := strings.TrimSpace(cell.Text())
|
|
|
|
|
|
texts = append(texts, text)
|
|
|
|
|
|
})
|
|
|
|
|
|
return texts
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func safeGet(arr []string, idx int) string {
|
|
|
|
|
|
if idx < len(arr) {
|
|
|
|
|
|
return arr[idx]
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
2026-06-09 23:08:03 +08:00
|
|
|
|
}
|