From da1e3a270a690f60341d1cd9f0f95a2f32cdba87 Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Sun, 25 Oct 2026 22:27:30 +0800 Subject: [PATCH] refactor(school): consume shared USTC course table parser --- go.mod | 2 +- go.sum | 4 +- internal/school/client.go | 109 ++++----------------------- internal/school/course_table_test.go | 83 ++++++++++++++++++++ 4 files changed, 102 insertions(+), 96 deletions(-) create mode 100644 internal/school/course_table_test.go diff --git a/go.mod b/go.mod index ee071a0..fe43bfc 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Life-USTC/CLI go 1.26.0 require ( - github.com/Life-USTC/client-core v0.4.0 + github.com/Life-USTC/client-core v0.5.0 github.com/PuerkitoBio/goquery v1.13.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 diff --git a/go.sum b/go.sum index 423732c..35ad7b8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Life-USTC/client-core v0.4.0 h1:3Bif0F0mMt4EtievcIpGEeLHFnBnY4C2XP2byGwPySg= -github.com/Life-USTC/client-core v0.4.0/go.mod h1:2SgYKZ3hbEFEigNGPdbfFS2m149evVw2t/mEjA/S/LM= +github.com/Life-USTC/client-core v0.5.0 h1:TkSp/rP5V5rastpV8BtbTwR7IlwKh1b7++430Ccf3QQ= +github.com/Life-USTC/client-core v0.5.0/go.mod h1:2SgYKZ3hbEFEigNGPdbfFS2m149evVw2t/mEjA/S/LM= github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM= github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= diff --git a/internal/school/client.go b/internal/school/client.go index e9006fc..afc45e9 100644 --- a/internal/school/client.go +++ b/internal/school/client.go @@ -13,14 +13,13 @@ import ( "strconv" "strings" + "github.com/Life-USTC/client-core/ustc" "github.com/PuerkitoBio/goquery" ) const ( catalogSemesterURL = "https://catalog.ustc.edu.cn/api/teach/semester/list" catalogLessonURL = "https://catalog.ustc.edu.cn/api/teach/lesson/list-for-teach/%d?page=%d&pageSize=%d" - jwCourseTableURL = "https://jw.ustc.edu.cn/for-std/course-table" - jwCourseDataURL = "https://jw.ustc.edu.cn/for-std/course-table/get-data?bizTypeId=2&semesterId=%d&dataId=%s" jwExamURL = "https://jw.ustc.edu.cn/for-std/exam-arrange" jwScoreSemestersURL = "https://jw.ustc.edu.cn/for-std/grade/sheet/getSemesters" jwScoreTypesURL = "https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeSheetTypes" @@ -585,7 +584,7 @@ func filterBlackboardCourseCalendarIDs(calendars []blackboardCalendar) []string } func fetchStudentID(ctx context.Context, client *http.Client) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwCourseTableURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustc.CourseTablePageURL, nil) if err != nil { return "", err } @@ -598,11 +597,11 @@ func fetchStudentID(ctx context.Context, client *http.Client) (string, error) { return "", responseError(res) } - return extractStudentIDFromURL(res.Request.URL.String()) + return ustc.StudentIDFromCourseTableURL(res.Request.URL.String()) } func fetchCurrentCourseTable(ctx context.Context, client *http.Client, semesterID int, studentID string) ([]int, []catalogLesson, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(jwCourseDataURL, semesterID, studentID), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustc.CourseTableDataURL(semesterID, studentID), nil) if err != nil { return nil, nil, err } @@ -615,40 +614,22 @@ func fetchCurrentCourseTable(ctx context.Context, client *http.Client, semesterI return nil, nil, responseError(res) } - var payload struct { - StudentTableVm struct { - LessonIDs []int `json:"lessonIds"` - Lessons []catalogLesson `json:"lessons"` - } `json:"studentTableVm"` - LessonIDs []int `json:"lessonIds"` - Lessons []catalogLesson `json:"lessons"` - LessonID2Lesson map[string]catalogLesson `json:"lessonId2Lesson"` - } - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - return nil, nil, fmt.Errorf("decode course table data: %w", err) + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, nil, fmt.Errorf("read course table data: %w", err) } - - lessonIDs := payload.StudentTableVm.LessonIDs - if len(lessonIDs) == 0 { - lessonIDs = payload.LessonIDs + table, err := ustc.ParseCourseTable(data) + if err != nil { + return nil, nil, err } - if len(lessonIDs) == 0 { + if len(table.LessonIDs) == 0 { return nil, nil, fmt.Errorf("%w for semester %d", errNoLessonIDs, semesterID) } - - slices.Sort(lessonIDs) - lessons := payload.StudentTableVm.Lessons - if len(lessons) == 0 { - lessons = payload.Lessons + lessons := make([]catalogLesson, len(table.Lessons)) + for i, lesson := range table.Lessons { + lessons[i] = catalogLesson(lesson) } - if len(lessons) == 0 && len(payload.LessonID2Lesson) > 0 { - for _, lessonID := range lessonIDs { - if lesson, ok := payload.LessonID2Lesson[strconv.Itoa(lessonID)]; ok { - lessons = append(lessons, lesson) - } - } - } - return slices.Compact(lessonIDs), lessons, nil + return table.LessonIDs, lessons, nil } func fetchCatalogLessons(ctx context.Context, client *http.Client, semesterID int) ([]catalogLesson, error) { @@ -1064,70 +1045,12 @@ func attrOr(selection *goquery.Selection, name, fallback string) string { return fallback } -func extractStudentIDFromURL(rawURL string) (string, error) { - re := regexp.MustCompile(`/course-table/(?:info/)?(\d+)`) - match := re.FindStringSubmatch(rawURL) - if len(match) != 2 { - return "", fmt.Errorf("could not extract jw student id from %s", rawURL) - } - return match[1], nil -} - type pageResponse[T any] struct { Rows []T `json:"rows"` TotalPage int `json:"totalPage"` } -type catalogLesson struct { - ID int `json:"id"` - Code string `json:"code"` - DateTimePlacePersonText localizedOrString `json:"dateTimePlacePersonText"` - CourseCode string `json:"courseCode"` - CourseName string `json:"courseName"` - Credits float64 `json:"credits"` - Course catalogCourse `json:"course"` - TeacherAssignmentList []catalogTeacherAssignment `json:"teacherAssignmentList"` -} - -type catalogTeacherAssignment struct { - CN string `json:"cn"` - EN string `json:"en"` - Teacher struct { - NameZh string `json:"nameZh"` - NameEn string `json:"nameEn"` - } `json:"teacher"` -} - -type catalogCourse struct { - Code string `json:"code"` - NameZh string `json:"nameZh"` - NameEn string `json:"nameEn"` - CN string `json:"cn"` - EN string `json:"en"` - Credits float64 `json:"credits"` -} - -type localizedOrString struct { - Value string -} - -func (l *localizedOrString) UnmarshalJSON(data []byte) error { - var text string - if err := json.Unmarshal(data, &text); err == nil { - l.Value = text - return nil - } - - var localized struct { - CN string `json:"cn"` - EN string `json:"en"` - } - if err := json.Unmarshal(data, &localized); err != nil { - return err - } - l.Value = firstNonEmpty(localized.CN, localized.EN) - return nil -} +type catalogLesson ustc.Lesson func (l catalogLesson) toCurriculumItem(semesterID int) CurriculumItem { teachers := make([]string, 0, len(l.TeacherAssignmentList)) diff --git a/internal/school/course_table_test.go b/internal/school/course_table_test.go new file mode 100644 index 0000000..6c627d6 --- /dev/null +++ b/internal/school/course_table_test.go @@ -0,0 +1,83 @@ +package school + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "testing" +) + +type courseTableTransport func(*http.Request) (*http.Response, error) + +func (f courseTableTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestCourseTableTransportAndEmbeddedProjection(t *testing.T) { + t.Parallel() + var requests []string + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.URL.String()) + responseRequest := r.Clone(r.Context()) + body := "" + if r.URL.Path == "/for-std/course-table" { + responseRequest.URL, _ = url.Parse("https://jw.ustc.edu.cn/for-std/course-table/info/0012345") + } else { + body = `{"studentTableVm":{"lessonIds":[30,10,30],"lessons":[{"id":30,"code":"CS.01","course":{"code":"CS","nameZh":"计算机","credits":4},"dateTimePlacePersonText":{"cn":"星期一"},"teacherAssignmentList":[{"teacher":{"nameZh":"张老师"}}]}]}}` + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), Request: responseRequest}, nil + })} + student, err := fetchStudentID(context.Background(), client) + if err != nil || student != "0012345" { + t.Fatalf("student=%q err=%v", student, err) + } + ids, lessons, err := fetchCurrentCourseTable(context.Background(), client, 301, student) + if err != nil || !reflect.DeepEqual(ids, []int{10, 30}) || len(lessons) != 1 { + t.Fatalf("ids=%v lessons=%v err=%v", ids, lessons, err) + } + item := lessons[0].toCurriculumItem(301) + if item.CourseName != "计算机" || item.CourseCode != "CS" || item.Credits != 4 { + t.Fatalf("projection=%+v", item) + } + want := []string{"https://jw.ustc.edu.cn/for-std/course-table", "https://jw.ustc.edu.cn/for-std/course-table/get-data?bizTypeId=2&semesterId=301&dataId=0012345"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%v", requests) + } +} + +func TestCourseTableRetainsEmptySemesterAndTransportFailures(t *testing.T) { + t.Parallel() + networkErr := errors.New("offline") + for _, tc := range []struct { + name, body string + status int + transportErr error + empty bool + }{ + {name: "empty semester", body: `{"lessonIds":[]}`, status: 200, empty: true}, + {name: "malformed response", body: `{`, status: 200}, + {name: "upstream failure", body: `unavailable`, status: 503}, + {name: "transport failure", transportErr: networkErr}, + } { + t.Run(tc.name, func(t *testing.T) { + client := &http.Client{Transport: courseTableTransport(func(r *http.Request) (*http.Response, error) { + if tc.transportErr != nil { + return nil, tc.transportErr + } + return &http.Response{StatusCode: tc.status, Body: io.NopCloser(strings.NewReader(tc.body)), Header: make(http.Header), Request: r}, nil + })} + _, _, err := fetchCurrentCourseTable(context.Background(), client, 301, "12345") + if err == nil { + t.Fatal("expected error") + } + if IsNoLessonData(err) != tc.empty { + t.Fatalf("empty-semester classification: %v", err) + } + if tc.transportErr != nil && !errors.Is(err, networkErr) { + t.Fatalf("lost transport error: %v", err) + } + }) + } +}