Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
109 changes: 16 additions & 93 deletions internal/school/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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))
Expand Down
83 changes: 83 additions & 0 deletions internal/school/course_table_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading