From 9953c0fb5bf76953834fca39c3232dd6cf2c47f3 Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Mon, 1 Jun 2026 11:58:08 +0800 Subject: [PATCH 1/6] Add school program config defaults --- internal/cmd/configcmd/config.go | 57 +++++++++++++++++++++++++++ internal/cmd/configcmd/config_test.go | 23 +++++++++++ internal/config/config.go | 27 +++++++++++-- 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/configcmd/config_test.go diff --git a/internal/cmd/configcmd/config.go b/internal/cmd/configcmd/config.go index 20d6910..8c06e0a 100644 --- a/internal/cmd/configcmd/config.go +++ b/internal/cmd/configcmd/config.go @@ -2,6 +2,7 @@ package configcmd import ( "fmt" + "strings" "github.com/spf13/cobra" @@ -27,6 +28,8 @@ func NewCmdConfig() *cobra.Command { } cmd.AddCommand(newCmdSetServer()) cmd.AddCommand(newCmdGetServer()) + cmd.AddCommand(newCmdSetSchoolPrograms()) + cmd.AddCommand(newCmdGetSchoolPrograms()) return cmd } @@ -54,3 +57,57 @@ func newCmdGetServer() *cobra.Command { }, } } + +func newCmdSetSchoolPrograms() *cobra.Command { + return &cobra.Command{ + Use: "set-school-programs ", + Short: "Set default school program selection", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + programs, err := parseSchoolPrograms(args[0]) + if err != nil { + return err + } + if err := config.SetSchoolPrograms(programs); err != nil { + return err + } + output.Success(fmt.Sprintf("Default school programs set to %s", strings.Join(programs, ","))) + return nil + }, + } +} + +func newCmdGetSchoolPrograms() *cobra.Command { + return &cobra.Command{ + Use: "get-school-programs", + Short: "Show default school program selection", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(strings.Join(config.GetSchoolPrograms(), ",")) + }, + } +} + +func parseSchoolPrograms(value string) ([]string, error) { + seen := map[string]struct{}{} + var programs []string + for _, part := range strings.Split(value, ",") { + program := strings.ToLower(strings.TrimSpace(part)) + switch program { + case "undergrad", "undergraduate": + program = "undergraduate" + case "grad", "graduate": + program = "graduate" + default: + return nil, fmt.Errorf("invalid school program %q; use undergraduate, graduate, or undergraduate,graduate", strings.TrimSpace(part)) + } + if _, ok := seen[program]; ok { + continue + } + seen[program] = struct{}{} + programs = append(programs, program) + } + if len(programs) == 0 { + return nil, fmt.Errorf("at least one school program is required") + } + return programs, nil +} diff --git a/internal/cmd/configcmd/config_test.go b/internal/cmd/configcmd/config_test.go new file mode 100644 index 0000000..69398b4 --- /dev/null +++ b/internal/cmd/configcmd/config_test.go @@ -0,0 +1,23 @@ +package configcmd + +import "testing" + +func TestParseSchoolPrograms(t *testing.T) { + t.Parallel() + + got, err := parseSchoolPrograms("undergrad,graduate,graduate") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0] != "undergraduate" || got[1] != "graduate" { + t.Fatalf("parseSchoolPrograms() = %v, want undergraduate, graduate", got) + } +} + +func TestParseSchoolProgramsRejectsInvalid(t *testing.T) { + t.Parallel() + + if _, err := parseSchoolPrograms("phd"); err == nil { + t.Fatal("parseSchoolPrograms() expected an error") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index f8d26ad..2ee870e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -97,7 +97,8 @@ func atomicWriteJSON(path string, data any, mode os.FileMode) error { // --- Global config --- type Config struct { - Server string `json:"server,omitempty"` + Server string `json:"server,omitempty"` + SchoolPrograms []string `json:"schoolPrograms,omitempty"` } func LoadConfig() (*Config, error) { @@ -121,9 +122,10 @@ func SaveConfig(cfg *Config) error { } // GetDefaultServer returns the server URL using this precedence: -// 1. LIFE_USTC_SERVER environment variable -// 2. Config file server field -// 3. Built-in DefaultServer +// 1. LIFE_USTC_SERVER environment variable +// 2. Config file server field +// 3. Built-in DefaultServer +// // The --server flag overrides all of these at the command level. func GetDefaultServer() string { if server := os.Getenv("LIFE_USTC_SERVER"); server != "" { @@ -145,6 +147,23 @@ func SetDefaultServer(server string) error { return SaveConfig(cfg) } +func GetSchoolPrograms() []string { + cfg, err := LoadConfig() + if err != nil || cfg == nil { + return nil + } + return append([]string(nil), cfg.SchoolPrograms...) +} + +func SetSchoolPrograms(programs []string) error { + cfg, _ := LoadConfig() + if cfg == nil { + cfg = &Config{} + } + cfg.SchoolPrograms = append([]string(nil), programs...) + return SaveConfig(cfg) +} + // --- Credentials (per server) --- type Credential struct { From fecc1d4222980c621def9f38ad8659f6ff7de35d Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Mon, 1 Jun 2026 11:58:11 +0800 Subject: [PATCH 2/6] Add official school platform clients --- go.mod | 4 + go.sum | 57 ++ internal/school/browser.go | 348 +++++++ internal/school/client.go | 1272 ++++++++++++++++++++++++++ internal/school/client_test.go | 149 +++ internal/school/credentials.go | 122 +++ internal/school/credentials_test.go | 142 +++ internal/school/debug.go | 39 + internal/school/graduate.go | 709 ++++++++++++++ internal/school/graduate_test.go | 135 +++ internal/school/models.go | 138 +++ internal/school/program.go | 12 + internal/school/score_parser_test.go | 78 ++ internal/school/totp.go | 121 +++ 14 files changed, 3326 insertions(+) create mode 100644 internal/school/browser.go create mode 100644 internal/school/client.go create mode 100644 internal/school/client_test.go create mode 100644 internal/school/credentials.go create mode 100644 internal/school/credentials_test.go create mode 100644 internal/school/debug.go create mode 100644 internal/school/graduate.go create mode 100644 internal/school/graduate_test.go create mode 100644 internal/school/models.go create mode 100644 internal/school/program.go create mode 100644 internal/school/score_parser_test.go create mode 100644 internal/school/totp.go diff --git a/go.mod b/go.mod index 148ea37..55d9ab0 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,13 @@ module github.com/Life-USTC/CLI go 1.25.9 require ( + github.com/PuerkitoBio/goquery v1.10.3 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/fatih/color v1.19.0 github.com/itchyny/gojq v0.12.19 + github.com/joho/godotenv v1.5.1 github.com/mattn/go-runewidth v0.0.23 github.com/oapi-codegen/runtime v1.4.0 github.com/spf13/cobra v1.10.2 @@ -16,6 +18,7 @@ require ( ) require ( + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -55,6 +58,7 @@ require ( github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index 44c2c0c..7bf761d 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -65,6 +69,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= @@ -78,6 +83,8 @@ github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= @@ -166,13 +173,24 @@ github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4Z github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -180,13 +198,27 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -204,22 +236,47 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/school/browser.go b/internal/school/browser.go new file mode 100644 index 0000000..fca673e --- /dev/null +++ b/internal/school/browser.go @@ -0,0 +1,348 @@ +package school + +import ( + "context" + "crypto/aes" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" +) + +const ( + jwLoginURL = "https://id.ustc.edu.cn/cas/login?service=https%3A%2F%2Fjw.ustc.edu.cn%2Fucas-sso%2Flogin" + bbLoginURL = "https://passport.ustc.edu.cn/login?service=https%3A%2F%2Fwww.bb.ustc.edu.cn%2Fwebapps%2Fbb-SSOIntegrationDemo-BBLEARN%2Fexecute%2FauthValidate%2FcustomLogin%3FreturnUrl%3Dhttp%3A%2F%2Fwww.bb.ustc.edu.cn%2Fwebapps%2Fportal%2Fframeset.jsp%26authProviderId%3D_103_1" + catalogLoginURL = "https://id.ustc.edu.cn/cas/login?service=https%3A%2F%2Fcatalog.ustc.edu.cn%2F" + authAttempts = 3 +) + +const schoolUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + +var errAlreadyAuthenticated = errors.New("already authenticated") + +type loginTarget struct { + loginURL string + expectedHost string + postLoginURLs []string +} + +type casLoginPage struct { + URL string + CryptoKey string + Execution string +} + +func newJWClient(ctx context.Context, creds Credentials) (*http.Client, error) { + return newAuthenticatedClient(ctx, creds, loginTarget{ + loginURL: jwLoginURL, + expectedHost: "jw.ustc.edu.cn", + postLoginURLs: []string{"https://jw.ustc.edu.cn/home", "https://jw.ustc.edu.cn/for-std/course-table"}, + }) +} + +func newBlackboardClient(ctx context.Context, creds Credentials) (*http.Client, error) { + return newAuthenticatedClientForTargets(ctx, creds, []loginTarget{ + { + loginURL: bbNginxAuthLoginURL(bbCalendarPageURL), + expectedHost: "www.bb.ustc.edu.cn", + postLoginURLs: []string{bbCalendarPageURL}, + }, + { + loginURL: bbLoginURL, + expectedHost: "www.bb.ustc.edu.cn", + postLoginURLs: []string{bbCalendarPageURL, bbCalendarListURL}, + }, + }) +} + +func bbNginxAuthLoginURL(rawURL string) string { + next := hex.EncodeToString([]byte(rawURL)) + service := "http://www.bb.ustc.edu.cn/nginx_auth/login.php?next=" + next + return "https://passport.ustc.edu.cn/login?service=" + url.QueryEscape(service) +} + +func newCatalogClient(ctx context.Context, creds Credentials) (*http.Client, error) { + return newAuthenticatedClient(ctx, creds, loginTarget{ + loginURL: catalogLoginURL, + expectedHost: "catalog.ustc.edu.cn", + postLoginURLs: []string{"https://catalog.ustc.edu.cn/", "https://catalog.ustc.edu.cn/api/restricted"}, + }) +} + +func newAuthenticatedClient(ctx context.Context, creds Credentials, target loginTarget) (*http.Client, error) { + return newAuthenticatedClientForTargets(ctx, creds, []loginTarget{target}) +} + +func newAuthenticatedClientForTargets(ctx context.Context, creds Credentials, targets []loginTarget) (*http.Client, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + var lastErr error + for attempt := 1; attempt <= authAttempts; attempt++ { + client, err := openAuthenticatedClientForTargets(ctx, creds, targets) + if err == nil { + return client, nil + } + lastErr = err + if attempt < authAttempts { + time.Sleep(time.Duration(attempt) * time.Second) + } + } + + if lastErr == nil { + lastErr = fmt.Errorf("failed to authenticate") + } + return nil, lastErr +} + +func openAuthenticatedClient(ctx context.Context, creds Credentials, target loginTarget) (*http.Client, error) { + return openAuthenticatedClientForTargets(ctx, creds, []loginTarget{target}) +} + +func openAuthenticatedClientForTargets(ctx context.Context, creds Credentials, targets []loginTarget) (*http.Client, error) { + client, err := newSchoolHTTPClient() + if err != nil { + return nil, err + } + for _, target := range targets { + if err := authenticateClient(ctx, client, creds, target); err != nil { + return nil, err + } + } + return client, nil +} + +func authenticateClient(ctx context.Context, client *http.Client, creds Credentials, target loginTarget) error { + loginPage, err := fetchCASLoginPage(ctx, client, target.loginURL) + if err != nil { + if errors.Is(err, errAlreadyAuthenticated) { + return primeAuthenticatedSession(ctx, client, target.postLoginURLs) + } + return err + } + + form, err := buildCASLoginForm(loginPage, creds) + if err != nil { + return err + } + + finalURL, body, err := submitCASLogin(ctx, client, loginPage, form) + if err != nil { + return err + } + if err := ensureLoginSucceeded(finalURL, body, target.expectedHost); err != nil { + return err + } + if err := primeAuthenticatedSession(ctx, client, target.postLoginURLs); err != nil { + return err + } + return nil +} + +func newSchoolHTTPClient() (*http.Client, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("create cookie jar: %w", err) + } + return &http.Client{ + Jar: jar, + Timeout: 60 * time.Second, + }, nil +} + +func fetchCASLoginPage(ctx context.Context, client *http.Client, loginURL string) (casLoginPage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL, nil) + if err != nil { + return casLoginPage{}, err + } + req.Header.Set("User-Agent", schoolUserAgent) + + res, err := client.Do(req) + if err != nil { + return casLoginPage{}, fmt.Errorf("open login page: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return casLoginPage{}, responseError(res) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return casLoginPage{}, fmt.Errorf("read login page: %w", err) + } + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body))) + if err != nil { + return casLoginPage{}, fmt.Errorf("parse login page: %w", err) + } + + page := casLoginPage{ + URL: res.Request.URL.String(), + CryptoKey: strings.TrimSpace(doc.Find("#login-croypto").First().Text()), + Execution: strings.TrimSpace(doc.Find("#login-page-flowkey").First().Text()), + } + if page.CryptoKey == "" || page.Execution == "" { + if isNonLoginHost(res.Request.URL.Host) { + return page, errAlreadyAuthenticated + } + return casLoginPage{}, fmt.Errorf("parse login page %s: missing auth fields", res.Request.URL) + } + return page, nil +} + +func isNonLoginHost(host string) bool { + return !strings.EqualFold(host, "id.ustc.edu.cn") && !strings.EqualFold(host, "passport.ustc.edu.cn") +} + +func buildCASLoginForm(page casLoginPage, creds Credentials) (url.Values, error) { + encryptedPassword, err := encryptCASPassword(creds.Password, page.CryptoKey) + if err != nil { + return nil, fmt.Errorf("encrypt CAS password: %w", err) + } + + form := url.Values{} + form.Set("username", creds.Username) + form.Set("password", encryptedPassword) + form.Set("type", "UsernamePassword") + form.Set("_eventId", "submit") + form.Set("geolocation", "") + form.Set("execution", page.Execution) + form.Set("croypto", page.CryptoKey) + return form, nil +} + +func submitCASLogin(ctx context.Context, client *http.Client, page casLoginPage, form url.Values) (*url.URL, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, page.URL, strings.NewReader(form.Encode())) + if err != nil { + return nil, nil, err + } + loginURL, err := url.Parse(page.URL) + if err != nil { + return nil, nil, fmt.Errorf("parse login url: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", loginURL.Scheme+"://"+loginURL.Host) + req.Header.Set("Referer", page.URL) + req.Header.Set("User-Agent", schoolUserAgent) + + res, err := client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("submit login form: %w", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, nil, fmt.Errorf("read login response: %w", err) + } + if res.StatusCode >= 300 { + return nil, nil, fmt.Errorf("submit login form returned %s", res.Status) + } + return res.Request.URL, body, nil +} + +func ensureLoginSucceeded(finalURL *url.URL, body []byte, expectedHost string) error { + if finalURL != nil && strings.EqualFold(finalURL.Host, expectedHost) { + return nil + } + + html := string(body) + if strings.Contains(strings.ToLower(html), "dynamic password") || strings.Contains(strings.ToLower(html), ">otp<") { + return fmt.Errorf("school login requires an OTP challenge that the HTTP client could not complete") + } + + if msg := extractCASLoginError(html); msg != "" { + return fmt.Errorf("school login failed: %s", msg) + } + + if finalURL == nil { + return fmt.Errorf("school login did not complete") + } + return fmt.Errorf("school login did not reach %s; ended at %s", expectedHost, finalURL.Host) +} + +func extractCASLoginError(html string) string { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return "" + } + + for _, selector := range []string{ + ".ant-form-item-explain-error", + ".alert-error", + ".alert-danger", + ".login-error", + ".error", + ".message", + } { + if text := strings.TrimSpace(doc.Find(selector).First().Text()); text != "" { + return compactWhitespace(text) + } + } + return "" +} + +func primeAuthenticatedSession(ctx context.Context, client *http.Client, urls []string) error { + for _, rawURL := range urls { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", schoolUserAgent) + + res, err := client.Do(req) + if err != nil { + return fmt.Errorf("prime authenticated session with %s: %w", rawURL, err) + } + if res.StatusCode >= 300 { + err = responseError(res) + res.Body.Close() + return err + } + if _, readErr := io.Copy(io.Discard, res.Body); readErr != nil { + res.Body.Close() + return fmt.Errorf("read %s: %w", rawURL, readErr) + } + res.Body.Close() + } + return nil +} + +func encryptCASPassword(password, keyBase64 string) (string, error) { + key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(keyBase64)) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + plain := pkcs7Pad([]byte(password), block.BlockSize()) + encrypted := make([]byte, len(plain)) + for offset := 0; offset < len(plain); offset += block.BlockSize() { + block.Encrypt(encrypted[offset:offset+block.BlockSize()], plain[offset:offset+block.BlockSize()]) + } + return base64.StdEncoding.EncodeToString(encrypted), nil +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + out := make([]byte, len(data)+padding) + copy(out, data) + for i := len(data); i < len(out); i++ { + out[i] = byte(padding) + } + return out +} diff --git a/internal/school/client.go b/internal/school/client.go new file mode 100644 index 0000000..d5d8447 --- /dev/null +++ b/internal/school/client.go @@ -0,0 +1,1272 @@ +package school + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + + "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" + jwScoreListURL = "https://jw.ustc.edu.cn/for-std/grade/sheet/getGradeList" + bbCalendarPageURL = "https://www.bb.ustc.edu.cn/webapps/calendar/viewPersonal" + bbCalendarListURL = "https://www.bb.ustc.edu.cn/webapps/calendar/calendarData/calendars?mode=personal&course_id=" + bbCalendarEventsURLBase = "https://www.bb.ustc.edu.cn/webapps/calendar/calendarData/events" +) + +var errNoLessonIDs = errors.New("jw returned no lesson ids") + +func IsNoLessonData(err error) bool { + return errors.Is(err, errNoLessonIDs) +} + +const ( + bbCalendarEventsStartMillis int64 = 0 + bbCalendarEventsEndMillis int64 = 4102444800000 + bbCalendarIDsPerRequestMax = 1800 +) + +type Client struct { + creds Credentials + program Program + graduateScheduleClient *http.Client + graduateScheduleTerms []graduateYJS1Term +} + +func NewClient(creds Credentials, programs ...Program) *Client { + program := ProgramUndergraduate + if len(programs) > 0 { + program = programs[0] + } + return &Client{creds: creds, program: program} +} + +func (c *Client) FetchSemesters(ctx context.Context) ([]Semester, error) { + if c.program.IsGraduate() { + return c.fetchGraduateSemesters(ctx) + } + client, err := newCatalogClient(ctx, c.creds) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, catalogSemesterURL, nil) + if err != nil { + return nil, err + } + + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch semesters: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + jwClient, err := newJWClient(ctx, c.creds) + if err != nil { + return nil, responseError(res) + } + return fetchJWScoreSemesters(ctx, jwClient) + } + + var semesters []Semester + if err := json.NewDecoder(res.Body).Decode(&semesters); err != nil { + return nil, fmt.Errorf("decode semesters: %w", err) + } + return semesters, nil +} + +func PickSemester(semesters []Semester, semesterID int) (Semester, error) { + if semesterID > 0 { + for _, semester := range semesters { + if semester.ID == semesterID { + return semester, nil + } + } + return Semester{}, fmt.Errorf("semester %d not found", semesterID) + } + for _, semester := range semesters { + if semester.IsLast { + return semester, nil + } + } + if len(semesters) == 0 { + return Semester{}, fmt.Errorf("no semester data returned from catalog") + } + current := semesters[0] + for _, semester := range semesters[1:] { + if semester.ID > current.ID { + current = semester + } + } + return current, nil +} + +func (c *Client) FetchCurriculum(ctx context.Context, semesterID int) (Semester, []CurriculumItem, error) { + if c.program.IsGraduate() { + return c.fetchGraduateCurriculum(ctx, semesterID) + } + + semesters, err := c.FetchSemesters(ctx) + if err != nil { + return Semester{}, nil, err + } + + jwClient, err := newJWClient(ctx, c.creds) + if err != nil { + return Semester{}, nil, err + } + studentID, err := fetchStudentID(ctx, jwClient) + if err != nil { + return Semester{}, nil, err + } + + semester, lessonIDs, jwLessons, err := pickCurriculumSemester(ctx, jwClient, semesters, semesterID, studentID) + if err != nil { + return Semester{}, nil, err + } + lessons := jwLessons + if semester.Name() != "" { + catalogClient, err := newCatalogClient(ctx, c.creds) + if err != nil { + return Semester{}, nil, err + } + catalogLessons, err := fetchCatalogLessons(ctx, catalogClient, semester.ID) + if err != nil { + if len(jwLessons) == 0 { + return Semester{}, nil, err + } + } else { + lessons = catalogLessons + } + } + if len(lessons) == 0 { + return Semester{}, nil, fmt.Errorf("no lesson details returned for semester %d", semester.ID) + } + + set := make(map[int]struct{}, len(lessonIDs)) + for _, lessonID := range lessonIDs { + set[lessonID] = struct{}{} + } + + items := make([]CurriculumItem, 0, len(set)) + for _, lesson := range lessons { + if _, ok := set[lesson.ID]; !ok { + continue + } + items = append(items, lesson.toCurriculumItem(semester.ID)) + } + SortCurriculum(items) + return semester, items, nil +} + +func pickCurriculumSemester(ctx context.Context, client *http.Client, semesters []Semester, semesterID int, studentID string) (Semester, []int, []catalogLesson, error) { + if semesterID > 0 { + semester, err := PickSemester(semesters, semesterID) + if err != nil { + return Semester{}, nil, nil, err + } + lessonIDs, lessons, err := fetchCurrentCourseTable(ctx, client, semester.ID, studentID) + return semester, lessonIDs, lessons, err + } + + selected, err := PickSemester(semesters, 0) + if err != nil { + return Semester{}, nil, nil, err + } + + candidates := append([]Semester{selected}, semesters...) + slices.SortFunc(candidates[1:], func(a, b Semester) int { + return b.ID - a.ID + }) + + seen := map[int]struct{}{} + var lastNoLessons error + for _, semester := range candidates { + if _, ok := seen[semester.ID]; ok { + continue + } + seen[semester.ID] = struct{}{} + + lessonIDs, lessons, err := fetchCurrentCourseTable(ctx, client, semester.ID, studentID) + if err == nil { + return semester, lessonIDs, lessons, nil + } + if errors.Is(err, errNoLessonIDs) { + lastNoLessons = err + continue + } + return Semester{}, nil, nil, err + } + if lastNoLessons != nil { + return Semester{}, nil, nil, lastNoLessons + } + return Semester{}, nil, nil, fmt.Errorf("no semester data returned from jw course table") +} + +func (c *Client) FetchExams(ctx context.Context, semesterIDs ...int) ([]ExamItem, error) { + if c.program.IsGraduate() { + semesterID := 0 + if len(semesterIDs) > 0 { + semesterID = semesterIDs[0] + } + return c.fetchGraduateExams(ctx, semesterID) + } + + jwClient, err := newJWClient(ctx, c.creds) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwExamURL, nil) + if err != nil { + return nil, err + } + res, err := jwClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch exams: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return nil, responseError(res) + } + + items, err := parseExamsHTML(res.Body) + if err != nil { + return nil, err + } + return items, nil +} + +func (c *Client) FetchScores(ctx context.Context) (ScoreReport, error) { + if c.program.IsGraduate() { + return c.fetchGraduateScores(ctx) + } + + jwClient, err := newJWClient(ctx, c.creds) + if err != nil { + return ScoreReport{}, err + } + report, err := fetchJWScoreReport(ctx, jwClient) + if err != nil { + return ScoreReport{}, err + } + return report, nil +} + +func (c *Client) FetchHomework(ctx context.Context) ([]HomeworkItem, error) { + if c.program.IsGraduate() { + return c.fetchGraduateHomework(ctx) + } + + var bbClient *http.Client + if err := withSchoolDebugStep("Blackboard authenticate", func() error { + var err error + bbClient, err = newBlackboardClient(ctx, c.creds) + return err + }); err != nil { + return nil, err + } + + var calendarIDs []string + if err := withSchoolDebugStep("Blackboard fetch calendars", func() error { + var err error + calendarIDs, err = fetchBlackboardCourseCalendarIDs(ctx, bbClient) + return err + }); err != nil { + return nil, err + } + debugLog("Blackboard calendars=%d", len(calendarIDs)) + + var courseIDs map[string]string + if err := withSchoolDebugStep("Blackboard fetch course ids", func() error { + var err error + courseIDs, err = fetchBlackboardCourseIDs(ctx, bbClient) + return err + }); err != nil { + return nil, err + } + debugLog("Blackboard course ids=%d", len(courseIDs)) + statusesByCourse := map[string]map[string]string{} + + itemsByID := make(map[string]HomeworkItem) + for _, batch := range batchCalendarIDs(calendarIDs, bbCalendarIDsPerRequestMax) { + var events []blackboardCalendarEvent + if err := withSchoolDebugStep(fmt.Sprintf("Blackboard fetch events calendars=%d", len(batch)), func() error { + var err error + events, err = fetchBlackboardCalendarEvents(ctx, bbClient, batch) + return err + }); err != nil { + return nil, err + } + debugLog("Blackboard events=%d", len(events)) + for _, event := range events { + lessonCode, semesterCode := splitBlackboardCalendarID(event.CalendarID) + status := event.EventType + internalCourseID := courseIDs[event.CalendarID] + if internalCourseID != "" && event.ItemSourceID != "" { + statuses, ok := statusesByCourse[internalCourseID] + if !ok { + if err := withSchoolDebugStep(fmt.Sprintf("Blackboard fetch grades course=%s internal=%s", event.CalendarID, internalCourseID), func() error { + var err error + statuses, err = fetchBlackboardGradeStatuses(ctx, bbClient, internalCourseID) + return err + }); err != nil { + return nil, err + } + statusesByCourse[internalCourseID] = statuses + } + if gradeStatus := statuses[event.ItemSourceID]; gradeStatus != "" { + status = gradeStatus + } + } + itemsByID[event.ID] = HomeworkItem{ + ID: event.ID, + Title: event.Title, + CourseName: event.CalendarName, + LessonCode: lessonCode, + SemesterCode: semesterCode, + ExternalItemID: event.ItemSourceID, + StartAt: event.Start, + EndAt: event.End, + Status: status, + } + } + } + + items := make([]HomeworkItem, 0, len(itemsByID)) + for _, item := range itemsByID { + items = append(items, item) + } + SortHomework(items) + return items, nil +} + +type blackboardCalendarPayload struct { + Calendars []blackboardCalendar `json:"calendars"` +} + +type blackboardCalendar struct { + ID string `json:"id"` +} + +type blackboardCalendarEvent struct { + ID string `json:"id"` + CalendarID string `json:"calendarId"` + ItemSourceID string `json:"itemSourceId"` + Title string `json:"title"` + Start string `json:"start"` + End string `json:"end"` + EventType string `json:"eventType"` + CalendarName string `json:"calendarName"` +} + +func fetchBlackboardCourseCalendarIDs(ctx context.Context, client *http.Client) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, bbCalendarListURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch Blackboard calendars: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return nil, responseError(res) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("read Blackboard calendars: %w", err) + } + var payload blackboardCalendarPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("decode Blackboard calendars: %w: %s", err, responseSnippet(body)) + } + return filterBlackboardCourseCalendarIDs(payload.Calendars), nil +} + +func fetchBlackboardCalendarEvents(ctx context.Context, client *http.Client, calendarIDs []string) ([]blackboardCalendarEvent, error) { + if len(calendarIDs) == 0 { + return nil, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildBlackboardCalendarEventsURL(calendarIDs), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch Blackboard homework: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return nil, responseError(res) + } + + var events []blackboardCalendarEvent + if err := json.NewDecoder(res.Body).Decode(&events); err != nil { + return nil, fmt.Errorf("decode Blackboard homework: %w", err) + } + return events, nil +} + +func fetchBlackboardCourseIDs(ctx context.Context, client *http.Client) (map[string]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.bb.ustc.edu.cn/webapps/portal/execute/tabs/tabAction?tab_tab_group_id=_1_1", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "text/html") + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch Blackboard portal courses: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return nil, responseError(res) + } + + doc, err := goquery.NewDocumentFromReader(res.Body) + if err != nil { + return nil, fmt.Errorf("parse Blackboard portal courses: %w", err) + } + out := map[string]string{} + doc.Find("a").Each(func(_ int, s *goquery.Selection) { + href, _ := s.Attr("href") + rawCourseID := strings.TrimSpace(strings.Split(strings.TrimSpace(s.Text()), ":")[0]) + if rawCourseID == "" { + return + } + parsed, err := url.Parse(strings.TrimSpace(href)) + if err != nil { + return + } + if parsed.Path != "/webapps/blackboard/execute/launcher" || parsed.Query().Get("type") != "Course" { + return + } + internalID := parsed.Query().Get("id") + if internalID != "" { + out[rawCourseID] = internalID + } + }) + return out, nil +} + +func fetchBlackboardGradeStatuses(ctx context.Context, client *http.Client, courseID string) (map[string]string, error) { + rawURL := "https://www.bb.ustc.edu.cn/webapps/gradebook/do/student/viewGrades?course_id=" + url.QueryEscape(courseID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "text/html") + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch Blackboard grades: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return nil, responseError(res) + } + + return blackboardGradeStatusesFromHTML(res.Body), nil +} + +func blackboardGradeStatusesFromHTML(reader io.Reader) map[string]string { + doc, err := goquery.NewDocumentFromReader(reader) + if err != nil { + return nil + } + out := map[string]string{} + doc.Find("#grades_wrapper .sortable_item_row").Each(func(_ int, s *goquery.Selection) { + id := strings.TrimSpace(s.AttrOr("id", "")) + if id == "" { + return + } + status := strings.TrimSpace(s.Find(".cell.activity .activityType").First().Text()) + if status == "" { + status = strings.TrimSpace(s.Find(".cell.gradeStatus").First().Text()) + } + if status != "" { + out["_"+id+"_1"] = compactWhitespace(status) + } + }) + return out +} + +func splitBlackboardCalendarID(calendarID string) (string, string) { + parts := strings.Split(strings.TrimSpace(calendarID), ".") + if len(parts) < 3 { + return strings.TrimSpace(calendarID), "" + } + semesterCode := parts[len(parts)-1] + if len(semesterCode) != 6 { + return strings.TrimSpace(calendarID), "" + } + for _, r := range semesterCode[:4] { + if r < '0' || r > '9' { + return strings.TrimSpace(calendarID), "" + } + } + switch semesterCode[4:] { + case "SP", "SU", "FA": + return strings.Join(parts[:len(parts)-1], "."), semesterCode + default: + return strings.TrimSpace(calendarID), "" + } +} + +func batchCalendarIDs(calendarIDs []string, maxQueryLength int) [][]string { + if len(calendarIDs) == 0 { + return nil + } + if maxQueryLength <= 0 { + return [][]string{append([]string(nil), calendarIDs...)} + } + + batches := make([][]string, 0, 1) + current := make([]string, 0, len(calendarIDs)) + currentLen := 0 + for _, calendarID := range calendarIDs { + extraLen := len(calendarID) + if len(current) > 0 { + extraLen++ + } + if currentLen > 0 && currentLen+extraLen > maxQueryLength { + batches = append(batches, current) + current = make([]string, 0, len(calendarIDs)) + currentLen = 0 + } + current = append(current, calendarID) + currentLen += extraLen + } + if len(current) > 0 { + batches = append(batches, current) + } + return batches +} + +func buildBlackboardCalendarEventsURL(calendarIDs []string) string { + return fmt.Sprintf( + "%s?start=%d&end=%d&course_id=&calendarIds=%s", + bbCalendarEventsURLBase, + bbCalendarEventsStartMillis, + bbCalendarEventsEndMillis, + strings.Join(calendarIDs, ","), + ) +} + +func filterBlackboardCourseCalendarIDs(calendars []blackboardCalendar) []string { + ids := make([]string, 0, len(calendars)) + for _, calendar := range calendars { + if calendar.ID == "" || calendar.ID == "PERSONAL" || calendar.ID == "INSTITUTION" { + continue + } + ids = append(ids, calendar.ID) + } + slices.Sort(ids) + return slices.Compact(ids) +} + +func fetchStudentID(ctx context.Context, client *http.Client) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwCourseTableURL, nil) + if err != nil { + return "", err + } + res, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch course table page: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return "", responseError(res) + } + + return extractStudentIDFromURL(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) + if err != nil { + return nil, nil, err + } + res, err := client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("fetch course table data: %w", err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + 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) + } + + lessonIDs := payload.StudentTableVm.LessonIDs + if len(lessonIDs) == 0 { + lessonIDs = payload.LessonIDs + } + if len(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 + } + 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 +} + +func fetchCatalogLessons(ctx context.Context, client *http.Client, semesterID int) ([]catalogLesson, error) { + var lessons []catalogLesson + for page := 1; ; page++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(catalogLessonURL, semesterID, page, 500), nil) + if err != nil { + return nil, err + } + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch catalog lessons: %w", err) + } + if res.StatusCode >= 300 { + err := responseError(res) + res.Body.Close() + return nil, err + } + + body, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return nil, fmt.Errorf("read catalog lessons: %w", err) + } + + var direct []catalogLesson + if err := json.Unmarshal(body, &direct); err == nil { + return direct, nil + } + + var payload pageResponse[catalogLesson] + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("decode catalog lessons: %w", err) + } + lessons = append(lessons, payload.Rows...) + if payload.TotalPage == 0 || page >= payload.TotalPage { + break + } + } + return lessons, nil +} + +func fetchJWScoreReport(ctx context.Context, client *http.Client) (ScoreReport, error) { + semesters, err := fetchJWScoreSemesters(ctx, client) + if err != nil { + return ScoreReport{}, err + } + if len(semesters) == 0 { + return ScoreReport{}, fmt.Errorf("jw returned no score semesters") + } + + types, err := fetchJWScoreTypes(ctx, client) + if err != nil { + return ScoreReport{}, err + } + if len(types) == 0 { + return ScoreReport{}, fmt.Errorf("jw returned no score transcript types") + } + + semesterIDs := make([]int, 0, len(semesters)) + semesterNames := make(map[int]string, len(semesters)) + for _, semester := range semesters { + semesterIDs = append(semesterIDs, semester.ID) + semesterNames[semester.ID] = semester.Name() + } + + payload, err := fetchJWScoreList(ctx, client, types[0].ID, semesterIDs) + if err != nil { + return ScoreReport{}, err + } + + summary, err := buildJWScoreSummary(payload.Overview, payload.StdGradeRank) + if err != nil { + return ScoreReport{}, err + } + + report := ScoreReport{ + Summary: summary, + Items: flattenJWScoreItems(payload.Semesters, semesterNames), + } + SortScores(report.Items) + return report, nil +} + +func fetchJWScoreSemesters(ctx context.Context, client *http.Client) ([]Semester, error) { + var semesters []Semester + if err := fetchJSON(ctx, client, jwScoreSemestersURL, nil, &semesters); err != nil { + return nil, fmt.Errorf("fetch score semesters: %w", err) + } + return semesters, nil +} + +func fetchJWScoreTypes(ctx context.Context, client *http.Client) ([]scoreSheetType, error) { + var types []scoreSheetType + if err := fetchJSON(ctx, client, jwScoreTypesURL, nil, &types); err != nil { + return nil, fmt.Errorf("fetch score transcript types: %w", err) + } + return types, nil +} + +func fetchJWScoreList(ctx context.Context, client *http.Client, trainTypeID int, semesterIDs []int) (scoreListResponse, error) { + query := url.Values{} + query.Set("trainTypeId", strconv.Itoa(trainTypeID)) + query.Set("semesterIds", joinInts(semesterIDs)) + + var payload scoreListResponse + if err := fetchJSON(ctx, client, jwScoreListURL, query, &payload); err != nil { + return scoreListResponse{}, fmt.Errorf("fetch score list: %w", err) + } + return payload, nil +} + +func buildJWScoreSummary(overview scoreOverview, rank *scoreRankInfo) (json.RawMessage, error) { + summary := map[string]any{} + if overview.PassedCredits != 0 || overview.NotPassedCredits != 0 || overview.GPA != 0 || overview.WeightedScore != 0 || overview.ArithmeticScore != 0 { + summary["totalCredits"] = overview.PassedCredits + overview.NotPassedCredits + summary["earnedCredits"] = overview.PassedCredits + summary["failedCredits"] = overview.NotPassedCredits + summary["gpa"] = overview.GPA + summary["weightedAverage"] = overview.WeightedScore + summary["averageScore"] = overview.ArithmeticScore + } + if rank != nil { + summary["fromSemester"] = rank.StartSemesterName + summary["toSemester"] = rank.EndSemesterName + summary["periodGPA"] = rank.GPA + if rank.MajorName != "" && rank.MajorRank != 0 && rank.MajorStdCount != 0 { + summary["ranking"] = fmt.Sprintf("全校%s年级%s专业 GPA 排名 %d/%d", rank.Grade, rank.MajorName, rank.MajorRank, rank.MajorStdCount) + } + } + if len(summary) == 0 { + return nil, nil + } + return json.Marshal(summary) +} + +func flattenJWScoreItems(semesters []scoreSemesterScores, semesterNames map[int]string) []ScoreItem { + out := make([]ScoreItem, 0) + for _, semester := range semesters { + out = append(out, flattenScoreItems(semester.Scores, semester.ID, semesterNames[semester.ID])...) + } + return out +} + +func flattenScoreItems(items []scoreAPIItem, defaultSemesterID int, defaultSemesterName string) []ScoreItem { + out := make([]ScoreItem, 0, len(items)) + for _, item := range items { + semesterID := firstNonZeroInt(item.SemesterID, item.SemesterAssoc, defaultSemesterID) + out = append(out, ScoreItem{ + SemesterID: semesterID, + SemesterName: firstNonEmpty(item.SemesterName, item.SemesterCh, defaultSemesterName, item.Semester.SemesterCn, item.Semester.SemesterEn), + CourseName: firstNonEmpty(item.CourseName, item.CourseNameCh, item.Course.NameZh, item.Course.NameEn, item.CourseNameEn), + LessonCode: item.LessonCode, + CourseCode: firstNonEmpty(item.CourseCode, item.Course.Code), + Credits: firstNonZero(item.Credits, item.Course.Credits), + GradePoint: item.GradePoint, + Score: firstNonEmpty(item.ScoreCh, item.Score, item.ScoreText, item.Grade, item.ScoreEn), + GradeText: firstNonEmpty(item.GradeText, item.ScoreText), + }) + } + return out +} + +func fetchJSON[T any](ctx context.Context, client *http.Client, rawURL string, query url.Values, out *T) error { + if query != nil && len(query) > 0 { + rawURL += "?" + query.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("User-Agent", schoolUserAgent) + + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return responseError(res) + } + if err := json.NewDecoder(res.Body).Decode(out); err != nil { + return fmt.Errorf("decode %s: %w", rawURL, err) + } + return nil +} + +func postFormJSON[T any](ctx context.Context, client *http.Client, rawURL string, form url.Values, out *T) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + req.Header.Set("User-Agent", schoolUserAgent) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 300 { + return responseError(res) + } + if err := json.NewDecoder(res.Body).Decode(out); err != nil { + return fmt.Errorf("decode %s: %w", rawURL, err) + } + return nil +} + +func joinInts(values []int) string { + if len(values) == 0 { + return "" + } + parts := make([]string, len(values)) + for i, value := range values { + parts[i] = strconv.Itoa(value) + } + return strings.Join(parts, ",") +} + +func parseExamsHTML(r io.Reader) ([]ExamItem, error) { + doc, err := goquery.NewDocumentFromReader(r) + if err != nil { + return nil, fmt.Errorf("parse exams page: %w", err) + } + + items := make([]ExamItem, 0) + doc.Find("table tbody tr").Each(func(_ int, row *goquery.Selection) { + cells := row.Find("td") + if cells.Length() < 4 { + return + } + values := make([]string, 0, cells.Length()) + cells.Each(func(_ int, cell *goquery.Selection) { + values = append(values, compactWhitespace(cell.Text())) + }) + item := ExamItem{} + if len(values) > 0 { + item.CourseName = values[0] + } + if len(values) > 1 { + item.LessonCode = values[1] + } + if len(values) > 2 { + item.ExamType = values[2] + } + if len(values) > 3 { + item.DateTime = values[3] + } + if len(values) > 4 { + item.Location = values[4] + } + if len(values) > 5 { + item.Seat = values[5] + } + if len(values) > 6 { + item.Status = values[6] + } + if item.CourseName != "" { + items = append(items, item) + } + }) + + SortExams(items) + return items, nil +} + +func parseScoreReportHTML(r io.Reader) (ScoreReport, error) { + doc, err := goquery.NewDocumentFromReader(r) + if err != nil { + return ScoreReport{}, fmt.Errorf("parse rendered score page: %w", err) + } + + report := ScoreReport{} + if summary := parseScoreSummary(doc); len(summary) > 0 { + report.Summary, err = json.Marshal(summary) + if err != nil { + return ScoreReport{}, fmt.Errorf("marshal score summary: %w", err) + } + } + + semesterIDs := map[string]int{} + doc.Find(".history-table thead select option").Each(func(_ int, option *goquery.Selection) { + name := strings.TrimSpace(option.Text()) + if name == "" { + return + } + id, convErr := strconv.Atoi(strings.TrimSpace(attrOr(option, "value", ""))) + if convErr != nil || id == 0 { + return + } + semesterIDs[name] = id + }) + + doc.Find(".tab-content .tab-pane.active .semesters .semester").Each(func(_ int, semester *goquery.Selection) { + semesterName := normalizeSpace(semester.Find("h4").First().Text()) + semesterID := semesterIDs[semesterName] + + semester.Find("tbody tr").Each(func(_ int, row *goquery.Selection) { + cells := row.Find("td") + if cells.Length() < 5 { + return + } + + courseName, courseCode := parseScoreCourseCell(cells.Eq(0)) + if courseName == "" { + return + } + + report.Items = append(report.Items, ScoreItem{ + SemesterID: semesterID, + SemesterName: semesterName, + CourseName: courseName, + CourseCode: courseCode, + Credits: parseScoreNumber(cells.Eq(2).Text()), + GradePoint: parseScoreNumber(cells.Eq(3).Text()), + Score: normalizeSpace(cells.Eq(4).Text()), + }) + }) + }) + + if len(report.Items) == 0 { + return ScoreReport{}, fmt.Errorf("parse rendered score page: no score rows found") + } + + SortScores(report.Items) + return report, nil +} + +func parseScoreSummary(doc *goquery.Document) map[string]any { + summary := map[string]any{} + + rankText := normalizeSpace(doc.Find(".rankinfo").First().Text()) + if rankText != "" { + pattern := regexp.MustCompile(`^(.*?)\s*[–-]\s*(.*?)平均学分绩点(GPA)为\s*([0-9.]+),排名情况:\s*(.+)$`) + if parts := pattern.FindStringSubmatch(rankText); len(parts) == 5 { + summary["fromSemester"] = normalizeSpace(parts[1]) + summary["toSemester"] = normalizeSpace(parts[2]) + summary["periodGPA"] = parseSummaryValue(parts[3]) + summary["ranking"] = normalizeSpace(parts[4]) + } else { + summary["rankingText"] = rankText + } + } + + fields := map[string]string{ + "总学分": "totalCredits", + "已获学分": "earnedCredits", + "不及格学分": "failedCredits", + "GPA": "gpa", + "加权平均分": "weightedAverage", + "算术平均分": "averageScore", + } + doc.Find(".tab-content .tab-pane.active .overview li").Each(func(_ int, item *goquery.Selection) { + spans := item.Find("span") + if spans.Length() < 2 { + return + } + label := normalizeSpace(spans.Eq(0).Text()) + key, ok := fields[label] + if !ok { + return + } + summary[key] = parseSummaryValue(spans.Eq(1).Text()) + }) + + return summary +} + +func parseScoreCourseCell(cell *goquery.Selection) (string, string) { + clone := cell.Clone() + code := normalizeSpace(clone.Find("small").First().Text()) + clone.Find("small").Remove() + return normalizeSpace(clone.Text()), code +} + +func parseSummaryValue(value string) any { + value = normalizeSpace(value) + if value == "" { + return "" + } + if number, err := strconv.ParseFloat(value, 64); err == nil { + return number + } + return value +} + +func parseScoreNumber(value string) float64 { + value = normalizeSpace(value) + if value == "" { + return 0 + } + number, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0 + } + return number +} + +func normalizeSpace(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func splitTeachers(value string) []string { + parts := regexp.MustCompile(`[、,,;/;]+`).Split(value, -1) + teachers := make([]string, 0, len(parts)) + for _, part := range parts { + name := compactWhitespace(part) + if name != "" { + teachers = append(teachers, name) + } + } + slices.Sort(teachers) + return slices.Compact(teachers) +} + +func parseLeadingInt(value string) int { + match := regexp.MustCompile(`^\d+`).FindString(strings.TrimSpace(value)) + if match == "" { + return 0 + } + parsed, err := strconv.Atoi(match) + if err != nil { + return 0 + } + return parsed +} + +func attrOr(selection *goquery.Selection, name, fallback string) string { + if value, ok := selection.Attr(name); ok { + return value + } + 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 +} + +func (l catalogLesson) toCurriculumItem(semesterID int) CurriculumItem { + teachers := make([]string, 0, len(l.TeacherAssignmentList)) + for _, assignment := range l.TeacherAssignmentList { + name := firstNonEmpty(assignment.Teacher.NameZh, assignment.Teacher.NameEn, assignment.CN, assignment.EN) + if name != "" { + teachers = append(teachers, name) + } + } + return CurriculumItem{ + SemesterID: semesterID, + LessonID: l.ID, + LessonCode: l.Code, + CourseCode: firstNonEmpty(l.CourseCode, l.Course.Code), + CourseName: firstNonEmpty(l.CourseName, l.Course.NameZh, l.Course.NameEn, l.Course.CN, l.Course.EN), + Credits: firstNonZero(l.Credits, l.Course.Credits), + Teachers: slices.Compact(teachers), + Schedule: compactWhitespace(l.DateTimePlacePersonText.Value), + } +} + +type scoreAPIItem struct { + SemesterID int `json:"semesterId"` + SemesterAssoc int `json:"semesterAssoc"` + SemesterName string `json:"semesterName"` + SemesterCh string `json:"semesterCh"` + SemesterEn string `json:"semesterEn"` + LessonCode string `json:"lessonCode"` + CourseCode string `json:"courseCode"` + CourseName string `json:"courseName"` + CourseNameCh string `json:"courseNameCh"` + CourseNameEn string `json:"courseNameEn"` + Credits float64 `json:"credits"` + GradePoint float64 `json:"gp"` + Score string `json:"score"` + ScoreCh string `json:"scoreCh"` + ScoreEn string `json:"scoreEn"` + ScoreText string `json:"scoreText"` + Grade string `json:"grade"` + GradeText string `json:"gradeDetail"` + Course scoreAPICourse `json:"course"` + Semester Semester `json:"semester"` +} + +type scoreAPICourse struct { + Code string `json:"code"` + NameZh string `json:"nameZh"` + NameEn string `json:"nameEn"` + Credits float64 `json:"credits"` +} + +type scoreSheetType struct { + ID int `json:"id"` +} + +type scoreListResponse struct { + Overview scoreOverview `json:"overview"` + Semesters []scoreSemesterScores `json:"semesters"` + StdGradeRank *scoreRankInfo `json:"stdGradeRank"` +} + +type scoreSemesterScores struct { + ID int `json:"id"` + Scores []scoreAPIItem `json:"scores"` +} + +type scoreOverview struct { + PassedCredits float64 `json:"passedCredits"` + GPA float64 `json:"gpa"` + WeightedScore float64 `json:"weightedScore"` + NotPassedCredits float64 `json:"notPassedCredits"` + ArithmeticScore float64 `json:"arithmeticScore"` +} + +type scoreRankInfo struct { + StartSemesterName string `json:"startSemesterName"` + EndSemesterName string `json:"endSemesterName"` + GPA float64 `json:"gpa"` + Grade string `json:"grade"` + MajorName string `json:"majorName"` + MajorRank int `json:"majorRank"` + MajorStdCount int `json:"majorStdCount"` +} + +func responseError(res *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) + return fmt.Errorf("%s %s returned %s: %s", res.Request.Method, res.Request.URL, res.Status, strings.TrimSpace(string(body))) +} + +func responseSnippet(body []byte) string { + text := compactWhitespace(string(body)) + if len(text) > 200 { + text = text[:200] + } + return text +} + +func compactWhitespace(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func firstNonZero(values ...float64) float64 { + for _, value := range values { + if value != 0 { + return value + } + } + return 0 +} + +func firstNonZeroInt(values ...int) int { + for _, value := range values { + if value != 0 { + return value + } + } + return 0 +} diff --git a/internal/school/client_test.go b/internal/school/client_test.go new file mode 100644 index 0000000..41c8cb9 --- /dev/null +++ b/internal/school/client_test.go @@ -0,0 +1,149 @@ +package school + +import ( + "reflect" + "strings" + "testing" +) + +func TestFilterBlackboardCourseCalendarIDs(t *testing.T) { + t.Parallel() + + calendars := []blackboardCalendar{ + {ID: "PERSONAL"}, + {ID: "CS1001.01.2024FA"}, + {ID: "INSTITUTION"}, + {ID: "MATH2001.02.2024FA"}, + {ID: "CS1001.01.2024FA"}, + {ID: ""}, + } + + got := filterBlackboardCourseCalendarIDs(calendars) + want := []string{"CS1001.01.2024FA", "MATH2001.02.2024FA"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("filterBlackboardCourseCalendarIDs() = %v, want %v", got, want) + } +} + +func TestBatchCalendarIDs(t *testing.T) { + t.Parallel() + + calendarIDs := []string{"A123", "BC456", "DEF789", "GHIJ000"} + got := batchCalendarIDs(calendarIDs, 9) + want := [][]string{{"A123"}, {"BC456"}, {"DEF789"}, {"GHIJ000"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("batchCalendarIDs() = %v, want %v", got, want) + } +} + +func TestBuildBlackboardCalendarEventsURL(t *testing.T) { + t.Parallel() + + got := buildBlackboardCalendarEventsURL([]string{"CS1001.01.2024FA", "MATH2001.02.2024FA"}) + want := "https://www.bb.ustc.edu.cn/webapps/calendar/calendarData/events?start=0&end=4102444800000&course_id=&calendarIds=CS1001.01.2024FA,MATH2001.02.2024FA" + if got != want { + t.Fatalf("buildBlackboardCalendarEventsURL() = %q, want %q", got, want) + } +} + +func TestSplitBlackboardCalendarID(t *testing.T) { + t.Parallel() + + code, semester := splitBlackboardCalendarID("MATH3011.01.2023FA") + if code != "MATH3011.01" || semester != "2023FA" { + t.Fatalf("splitBlackboardCalendarID() = (%q, %q), want (%q, %q)", code, semester, "MATH3011.01", "2023FA") + } +} + +func TestFetchBlackboardGradeStatusesParsesRows(t *testing.T) { + t.Parallel() + + html := `
+
已评分
+
尚未评分
+
` + got := blackboardGradeStatusesFromHTML(strings.NewReader(html)) + if got["_77235_1"] != "已评分" { + t.Fatalf("status _77235_1 = %q, want 已评分", got["_77235_1"]) + } + if got["_59232_1"] != "尚未评分" { + t.Fatalf("status _59232_1 = %q, want 尚未评分", got["_59232_1"]) + } +} + +func TestParseExamsHTMLEmptyTableReturnsEmptySlice(t *testing.T) { + t.Parallel() + + items, err := parseExamsHTML(strings.NewReader(` + + +
+ `)) + if err != nil { + t.Fatalf("parseExamsHTML returned error: %v", err) + } + if items == nil { + t.Fatal("parseExamsHTML returned nil slice, want empty slice") + } + if len(items) != 0 { + t.Fatalf("parseExamsHTML returned %d items, want 0", len(items)) + } +} + +func TestParseExamsHTMLParsesAndSortsRows(t *testing.T) { + t.Parallel() + + items, err := parseExamsHTML(strings.NewReader(` + + + + + + + + + + + + + + + + + + + + + + + +
高等数学MATH1001.01期末2025-07-02 08:00三教3A10118待参加闭卷
程序设计基础CS1001.01期中2025-06-30 14:00东区2A20127待参加机考
+ `)) + if err != nil { + t.Fatalf("parseExamsHTML returned error: %v", err) + } + + want := []ExamItem{ + { + CourseName: "程序设计基础", + LessonCode: "CS1001.01", + ExamType: "期中", + DateTime: "2025-06-30 14:00", + Location: "东区2A201", + Seat: "27", + Status: "待参加", + }, + { + CourseName: "高等数学", + LessonCode: "MATH1001.01", + ExamType: "期末", + DateTime: "2025-07-02 08:00", + Location: "三教3A101", + Seat: "18", + Status: "待参加", + }, + } + if !reflect.DeepEqual(items, want) { + t.Fatalf("parseExamsHTML() = %#v, want %#v", items, want) + } +} diff --git a/internal/school/credentials.go b/internal/school/credentials.go new file mode 100644 index 0000000..352c7f9 --- /dev/null +++ b/internal/school/credentials.go @@ -0,0 +1,122 @@ +package school + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" +) + +var ( + usernameEnvKeys = []string{ + "PASSPORT_UNDERGRADUATE_USERNAME", + } + graduateUsernameEnvKeys = []string{ + "PASSPORT_GRADUATE_USERNAME", + } + passwordEnvKeys = []string{ + "PASSPORT_PASSWORD", + } + totpEnvKeys = []string{ + "PASSPORT_TOTP", + } +) + +func ResolveCredentials(username, password, totp string) (Credentials, error) { + return ResolveCredentialsForProgram(ProgramUndergraduate, username, password, totp) +} + +func ResolveCredentialsForProgram(program Program, username, password, totp string) (Credentials, error) { + envFile := readDotEnv() + selectedUsernameKeys := usernameEnvKeys + if program.IsGraduate() { + selectedUsernameKeys = graduateUsernameEnvKeys + } + creds := Credentials{ + Username: firstNonEmpty(strings.TrimSpace(username), firstEnvOrFile(envFile, selectedUsernameKeys...)), + Password: firstNonEmpty(password, firstEnvOrFile(envFile, passwordEnvKeys...)), + TOTP: firstNonEmpty(strings.TrimSpace(totp), firstEnvOrFile(envFile, totpEnvKeys...)), + } + + var missing []string + if creds.Username == "" { + missing = append(missing, "username") + } + if creds.Password == "" { + missing = append(missing, "password") + } + if creds.TOTP == "" { + missing = append(missing, "totp") + } + if len(missing) > 0 { + return Credentials{}, fmt.Errorf( + "missing school credentials: %s (flags or env: %s / %s / %s)", + strings.Join(missing, ", "), + strings.Join(selectedUsernameKeys, ", "), + strings.Join(passwordEnvKeys, ", "), + strings.Join(totpEnvKeys, ", "), + ) + } + + return creds, nil +} + +func DetectCredentialPrograms() []Program { + envFile := readDotEnv() + var programs []Program + if firstEnvOrFile(envFile, "PASSPORT_UNDERGRADUATE_USERNAME") != "" { + programs = append(programs, ProgramUndergraduate) + } + if firstEnvOrFile(envFile, "PASSPORT_GRADUATE_USERNAME") != "" { + programs = append(programs, ProgramGraduate) + } + return programs +} + +func firstEnv(keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return "" +} + +func firstEnvOrFile(fileEnv map[string]string, keys ...string) string { + if value := firstEnv(keys...); value != "" { + return value + } + for _, key := range keys { + if value := strings.TrimSpace(fileEnv[key]); value != "" { + return value + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func readDotEnv() map[string]string { + wd, err := os.Getwd() + if err != nil { + return nil + } + + for dir := wd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + path := filepath.Join(dir, ".env") + values, err := godotenv.Read(path) + if err == nil { + return values + } + } + return nil +} diff --git a/internal/school/credentials_test.go b/internal/school/credentials_test.go new file mode 100644 index 0000000..5e1e9b6 --- /dev/null +++ b/internal/school/credentials_test.go @@ -0,0 +1,142 @@ +package school + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestResolveCredentialsFallsBackToDotEnv(t *testing.T) { + t.Setenv("PASSPORT_UNDERGRADUATE_USERNAME", "") + t.Setenv("PASSPORT_GRADUATE_USERNAME", "") + t.Setenv("PASSPORT_PASSWORD", "") + t.Setenv("PASSPORT_TOTP", "") + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("PASSPORT_UNDERGRADUATE_USERNAME=test-user\nPASSPORT_PASSWORD=test-pass\nPASSPORT_TOTP=123456\n"), 0o644); err != nil { + t.Fatal(err) + } + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(cwd); err != nil { + t.Fatal(err) + } + }() + + creds, err := ResolveCredentials("", "", "") + if err != nil { + t.Fatal(err) + } + if creds.Username != "test-user" || creds.Password != "test-pass" || creds.TOTP != "123456" { + t.Fatalf("ResolveCredentials() = %+v", creds) + } +} + +func TestResolveGraduateCredentialsPreferGraduateUsername(t *testing.T) { + t.Setenv("PASSPORT_GRADUATE_USERNAME", "graduate-specific") + t.Setenv("PASSPORT_UNDERGRADUATE_USERNAME", "undergrad-alias") + t.Setenv("PASSPORT_PASSWORD", "shared-pass") + t.Setenv("PASSPORT_TOTP", "") + + creds, err := ResolveCredentialsForProgram(ProgramGraduate, "", "", "") + if err != nil { + t.Fatal(err) + } + if creds.Username != "graduate-specific" || creds.Password != "shared-pass" { + t.Fatalf("ResolveCredentialsForProgram() = %+v", creds) + } +} + +func TestDetectCredentialProgramsUsesProgramSpecificUsernames(t *testing.T) { + chdirTemp(t) + t.Setenv("PASSPORT_UNDERGRADUATE_USERNAME", "undergrad-user") + t.Setenv("PASSPORT_GRADUATE_USERNAME", "graduate-user") + + programs := DetectCredentialPrograms() + if len(programs) != 2 || programs[0] != ProgramUndergraduate || programs[1] != ProgramGraduate { + t.Fatalf("DetectCredentialPrograms() = %v, want undergraduate and graduate", programs) + } +} + +func TestDetectCredentialProgramsReturnsNoneWithoutProgramUsernames(t *testing.T) { + chdirTemp(t) + t.Setenv("PASSPORT_UNDERGRADUATE_USERNAME", "") + t.Setenv("PASSPORT_GRADUATE_USERNAME", "") + + if programs := DetectCredentialPrograms(); len(programs) != 0 { + t.Fatalf("DetectCredentialPrograms() = %v, want none", programs) + } +} + +func chdirTemp(t *testing.T) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatal(err) + } + }) +} + +func TestCurrentOTPFromOtpauthURL(t *testing.T) { + code, err := CurrentOTP("otpauth://totp/LifeUSTC:test?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30", time.Unix(59, 0)) + if err != nil { + t.Fatal(err) + } + if code != "94287082" { + t.Fatalf("CurrentOTP() = %q, want %q", code, "94287082") + } +} + +func TestPickSemesterPrefersRequestedOrCurrent(t *testing.T) { + semesters := []Semester{ + {ID: 1, SemesterCn: "Older"}, + {ID: 2, SemesterCn: "Current", IsLast: true}, + } + + current, err := PickSemester(semesters, 0) + if err != nil { + t.Fatal(err) + } + if current.ID != 2 { + t.Fatalf("current semester ID = %d, want 2", current.ID) + } + + selected, err := PickSemester(semesters, 1) + if err != nil { + t.Fatal(err) + } + if selected.ID != 1 { + t.Fatalf("selected semester ID = %d, want 1", selected.ID) + } +} + +func TestPickSemesterFallsBackToHighestID(t *testing.T) { + semesters := []Semester{ + {ID: 381}, + {ID: 362}, + {ID: 221}, + } + + current, err := PickSemester(semesters, 0) + if err != nil { + t.Fatal(err) + } + if current.ID != 381 { + t.Fatalf("current semester ID = %d, want 381", current.ID) + } +} diff --git a/internal/school/debug.go b/internal/school/debug.go new file mode 100644 index 0000000..9356902 --- /dev/null +++ b/internal/school/debug.go @@ -0,0 +1,39 @@ +package school + +import ( + "fmt" + "time" +) + +var debugf func(format string, args ...any) + +func SetDebugLogger(fn func(format string, args ...any)) { + debugf = fn +} + +func debugLog(format string, args ...any) { + if debugf != nil { + debugf(format, args...) + } +} + +func debugStep(name string) func() { + if debugf == nil { + return func() {} + } + start := time.Now() + debugLog("start %s", name) + return func() { + debugLog("done %s (%s)", name, time.Since(start).Round(time.Millisecond)) + } +} + +func debugStepf(format string, args ...any) func() { + return debugStep(fmt.Sprintf(format, args...)) +} + +func withSchoolDebugStep(name string, fn func() error) error { + done := debugStep(name) + defer done() + return fn() +} diff --git a/internal/school/graduate.go b/internal/school/graduate.go new file mode 100644 index 0000000..713840b --- /dev/null +++ b/internal/school/graduate.go @@ -0,0 +1,709 @@ +package school + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + graduateYJS1ScheduleAppURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/xskbcx" + graduateYJS1ScheduleTermURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/modules/xskbcx/xnxqxxcx.do" + graduateYJS1ScheduleRowsURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kbcxappustc/modules/xskbcx/xskbxxcx.do" + + graduateYJS1ScoreAppURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/wdcj" + graduateYJS1ScoreInfoURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/queryInfo.do" + graduateYJS1ScoreProfileURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/hqxh.do" + graduateYJS1ScoreRowsURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdcjapp/modules/wdcj/xscjcx.do" + + graduateYJS1ExamAppURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/xskssbcx" + graduateYJS1ExamTermURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/modules/xskssbcx/hqxnxq.do" + graduateYJS1ExamRowsURL = "https://yjs1.ustc.edu.cn/gsapp/sys/kssbappustc/modules/xskssbcx/xscxkssbxx.do" + + graduateYJS1HomeworkAppURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/*default/index.do?THEME=blue&EMAP_LANG=zh&min=1#/wdzy" + graduateYJS1HomeworkTermURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/modules/wdzy/hqxnxq.do" + graduateYJS1HomeworkRowsURL = "https://yjs1.ustc.edu.cn/gsapp/sys/wdzyappustc/modules/wdzy/wdzycx.do" +) + +var graduateYJS1UserIDPattern = regexp.MustCompile(`"USERID":"([^"]+)"`) + +type graduateYJS1RowsResponse[T any] struct { + Code string `json:"code"` + Msg string `json:"msg"` + Datas map[string]graduateYJS1RowsResponseSet[T] `json:"datas"` +} + +type graduateYJS1RowsResponseSet[T any] struct { + Rows []T `json:"rows"` +} + +type graduateYJS1Term struct { + DM string `json:"DM"` + MC string `json:"MC"` + SFDQXQ string `json:"SFDQXQ"` + QSSJ string `json:"QSSJ"` + JZSJ string `json:"JZSJ"` +} + +type graduateYJS1ScheduleRow struct { + WID string `json:"WID"` + BJMC string `json:"BJMC"` + KCDM string `json:"KCDM"` + KCMC string `json:"KCMC"` + PKSJDD string `json:"PKSJDD"` + RKJS string `json:"RKJS"` + ZCMC string `json:"ZCMC"` +} + +type graduateYJS1ScoreInfoRow struct { + XH string `json:"XH"` + PYCCDMDisplay string `json:"PYCCDM_DISPLAY"` + ZYDMDisplay string `json:"ZYDM_DISPLAY"` + YXDMDisplay string `json:"YXDM_DISPLAY"` + DSXM string `json:"DSXM"` + NJDMDisplay string `json:"NJDM_DISPLAY"` + ZCZTDisplay string `json:"ZCZT_DISPLAY"` + YJBYSJ string `json:"YJBYSJ"` +} + +type graduateYJS1ScoreProfile struct { + Code string `json:"code"` + Msg string `json:"msg"` + XH string `json:"XH"` + XSCJXX graduateYJS1ScoreProfileRow `json:"xscjxx"` +} + +type graduateYJS1ScoreProfileRow struct { + PYCCDMDisplay string `json:"PYCCDM_DISPLAY"` + ZYDMDisplay string `json:"ZYDM_DISPLAY"` + YXDMDisplay string `json:"YXDM_DISPLAY"` + DSXM string `json:"DSXM"` + NJDMDisplay string `json:"NJDM_DISPLAY"` + ZCZTDisplay string `json:"ZCZT_DISPLAY"` + YJBYSJ string `json:"YJBYSJ"` + PJJDZ string `json:"PJJDZ"` +} + +type graduateYJS1ScoreRow struct { + XNXQDM string `json:"XNXQDM"` + XNXQDMDisplay string `json:"XNXQDM_DISPLAY"` + BJMC string `json:"BJMC"` + KCDM string `json:"KCDM"` + KCMC string `json:"KCMC"` + CJFZDMDisplay string `json:"CJFZDM_DISPLAY"` + CJJLDisplay string `json:"CJJL_DISPLAY"` + XF any `json:"XF"` + CJ any `json:"CJ"` + JDZ any `json:"JDZ"` +} + +type graduateYJS1ExamRow struct { + BJMC string `json:"BJMC"` + KCDM string `json:"KCDM"` + KCMC string `json:"KCMC"` + KSDD string `json:"KSDD"` + KSKSSJ string `json:"KSKSSJ"` + KSJSSJ string `json:"KSJSSJ"` + KSXS string `json:"KSXS"` + KSXSDisplay string `json:"KSXS_DISPLAY"` + SBZT string `json:"SBZT"` + SBZTDisplay string `json:"SBZT_DISPLAY"` + ZWH string `json:"ZWH"` +} + +type graduateYJS1HomeworkRow struct { + WID string `json:"WID"` + XSZYWID string `json:"XSZYWID"` + KCMC string `json:"KCMC"` + ZYMC string `json:"ZYMC"` + ZYKSSJ string `json:"ZYKSSJ"` + ZYJZSJ string `json:"ZYJZSJ"` + ZYSCSJ string `json:"ZYSCSJ"` + CJ any `json:"CJ"` +} + +func (c *Client) fetchGraduateSemesters(ctx context.Context) ([]Semester, error) { + _, terms, err := c.graduateScheduleSession(ctx) + if err != nil { + return nil, err + } + + return graduateYJS1SemestersFromTerms(terms), nil +} + +func (c *Client) fetchGraduateCurriculum(ctx context.Context, semesterID int) (Semester, []CurriculumItem, error) { + client, terms, err := c.graduateScheduleSession(ctx) + if err != nil { + return Semester{}, nil, err + } + + selected, err := pickGraduateYJS1Term(terms, semesterID) + if err != nil { + return Semester{}, nil, err + } + + querySetting, err := graduateYJS1QuerySetting([]graduateYJS1Query{ + {Name: "XNXQDM", Builder: "equal", LinkOpt: "AND", BuilderList: "cbl_String", Value: selected.Code}, + }) + if err != nil { + return Semester{}, nil, err + } + + rows, err := fetchGraduateYJS1Rows[graduateYJS1ScheduleRow](ctx, client, graduateYJS1ScheduleAppURL, graduateYJS1ScheduleRowsURL, url.Values{ + "querySetting": {querySetting}, + "pageSize": {"999"}, + "pageNumber": {"1"}, + }) + if err != nil { + return Semester{}, nil, err + } + + return selected, graduateYJS1CurriculumItemsFromRows(selected, rows), nil +} + +func (c *Client) graduateScheduleSession(ctx context.Context) (*http.Client, []graduateYJS1Term, error) { + if c.graduateScheduleClient == nil { + client, err := newGraduateYJS1Client(ctx, c.creds, graduateYJS1ScheduleAppURL) + if err != nil { + return nil, nil, err + } + c.graduateScheduleClient = client + } + if c.graduateScheduleTerms == nil { + terms, err := fetchGraduateYJS1Terms(ctx, c.graduateScheduleClient, graduateYJS1ScheduleAppURL, graduateYJS1ScheduleTermURL, url.Values{ + "SFSY": {"1"}, + "pageSize": {"100"}, + "pageNumber": {"1"}, + }) + if err != nil { + return nil, nil, err + } + c.graduateScheduleTerms = terms + } + return c.graduateScheduleClient, c.graduateScheduleTerms, nil +} + +func (c *Client) fetchGraduateExams(ctx context.Context, semesterID int) ([]ExamItem, error) { + client, err := newGraduateYJS1Client(ctx, c.creds, graduateYJS1ExamAppURL) + if err != nil { + return nil, err + } + + terms, err := fetchGraduateYJS1Terms(ctx, client, graduateYJS1ExamAppURL, graduateYJS1ExamTermURL, nil) + if err != nil { + return nil, err + } + selected, err := pickGraduateYJS1Term(terms, semesterID) + if err != nil { + return nil, err + } + + querySetting, err := graduateYJS1QuerySetting([]graduateYJS1Query{ + {Name: "XNXQDM", Caption: "学年学期", Builder: "m_value_equal", LinkOpt: "AND", Value: selected.Code}, + {Name: "SBZT", Caption: "申报状态", Builder: "equal", LinkOpt: "AND", Value: "1"}, + }) + if err != nil { + return nil, err + } + + rows, err := fetchGraduateYJS1Rows[graduateYJS1ExamRow](ctx, client, graduateYJS1ExamAppURL, graduateYJS1ExamRowsURL, url.Values{ + "querySetting": {querySetting}, + "pageSize": {"999"}, + "pageNumber": {"1"}, + }) + if err != nil { + return nil, err + } + + return graduateYJS1ExamItemsFromRows(rows), nil +} + +func (c *Client) fetchGraduateScores(ctx context.Context) (ScoreReport, error) { + client, err := newGraduateYJS1Client(ctx, c.creds, graduateYJS1ScoreAppURL) + if err != nil { + return ScoreReport{}, err + } + + infoRows, err := fetchGraduateYJS1Rows[graduateYJS1ScoreInfoRow](ctx, client, graduateYJS1ScoreAppURL, graduateYJS1ScoreInfoURL, nil) + if err != nil { + return ScoreReport{}, err + } + + var profile graduateYJS1ScoreProfile + if err := postGraduateYJS1FormJSON(ctx, client, graduateYJS1ScoreAppURL, graduateYJS1ScoreProfileURL, nil, &profile); err != nil { + return ScoreReport{}, err + } + if profile.Code != "" && profile.Code != "0" { + return ScoreReport{}, fmt.Errorf("graduate score profile request failed: %s", profile.Msg) + } + + rows, err := fetchGraduateYJS1Rows[graduateYJS1ScoreRow](ctx, client, graduateYJS1ScoreAppURL, graduateYJS1ScoreRowsURL, url.Values{ + "pageSize": {"999"}, + "pageNumber": {"1"}, + }) + if err != nil { + return ScoreReport{}, err + } + + report := ScoreReport{ + Items: graduateYJS1ScoreItemsFromRows(rows), + Summary: buildGraduateYJS1ScoreSummary(firstGraduateYJS1ScoreInfoRow(infoRows), profile), + } + SortScores(report.Items) + return report, nil +} + +func (c *Client) fetchGraduateHomework(ctx context.Context) ([]HomeworkItem, error) { + client, err := newGraduateYJS1Client(ctx, c.creds, graduateYJS1HomeworkAppURL) + if err != nil { + return nil, err + } + + terms, err := fetchGraduateYJS1Terms(ctx, client, graduateYJS1HomeworkAppURL, graduateYJS1HomeworkTermURL, url.Values{ + "*order": {"-XNDM,-XQDM"}, + }) + if err != nil { + return nil, err + } + selected, err := pickGraduateYJS1Term(terms, 0) + if err != nil { + return nil, err + } + + userID, err := fetchGraduateYJS1UserID(ctx, client, graduateYJS1HomeworkAppURL) + if err != nil { + return nil, err + } + + querySetting, err := graduateYJS1QuerySetting([]graduateYJS1Query{ + {Name: "XNXQDM", Builder: "m_value_equal", Value: selected.Code}, + }) + if err != nil { + return nil, err + } + + rows, err := fetchGraduateYJS1Rows[graduateYJS1HomeworkRow](ctx, client, graduateYJS1HomeworkAppURL, graduateYJS1HomeworkRowsURL, url.Values{ + "XH": {userID}, + "querySetting": {querySetting}, + "pageSize": {"999"}, + "pageNumber": {"1"}, + }) + if err != nil { + return nil, err + } + + return graduateYJS1HomeworkItemsFromRows(rows, time.Now()), nil +} + +type graduateYJS1Query struct { + Name string `json:"name"` + Caption string `json:"caption,omitempty"` + Builder string `json:"builder,omitempty"` + LinkOpt string `json:"linkOpt,omitempty"` + BuilderList string `json:"builderList,omitempty"` + Value string `json:"value,omitempty"` +} + +func graduateYJS1QuerySetting(filters []graduateYJS1Query) (string, error) { + data, err := json.Marshal(filters) + if err != nil { + return "", err + } + return string(data), nil +} + +func fetchGraduateYJS1Terms(ctx context.Context, client *http.Client, appPageURL, rawURL string, form url.Values) ([]graduateYJS1Term, error) { + return fetchGraduateYJS1Rows[graduateYJS1Term](ctx, client, appPageURL, rawURL, form) +} + +func fetchGraduateYJS1Rows[T any](ctx context.Context, client *http.Client, appPageURL, rawURL string, form url.Values) ([]T, error) { + var response graduateYJS1RowsResponse[T] + if err := postGraduateYJS1FormJSON(ctx, client, appPageURL, rawURL, form, &response); err != nil { + return nil, err + } + if response.Code != "" && response.Code != "0" { + return nil, fmt.Errorf("graduate yjs1 request failed: %s", response.Msg) + } + for _, data := range response.Datas { + return data.Rows, nil + } + return nil, nil +} + +func newGraduateYJS1Client(ctx context.Context, creds Credentials, appPageURL string) (*http.Client, error) { + return newAuthenticatedClient(ctx, creds, loginTarget{ + loginURL: graduateYJS1LoginURL(appPageURL), + expectedHost: "yjs1.ustc.edu.cn", + postLoginURLs: []string{appPageURL}, + }) +} + +func graduateYJS1LoginURL(appPageURL string) string { + return "https://id.ustc.edu.cn/cas/login?service=" + url.QueryEscape(appPageURL) +} + +func postGraduateYJS1FormJSON(ctx context.Context, client *http.Client, appPageURL, rawURL string, form url.Values, dest any) error { + body := "" + if form != nil { + body = form.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + req.Header.Set("Origin", "https://yjs1.ustc.edu.cn") + req.Header.Set("Referer", appPageURL) + req.Header.Set("User-Agent", schoolUserAgent) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + return responseError(res) + } + return json.NewDecoder(res.Body).Decode(dest) +} + +func fetchGraduateYJS1UserID(ctx context.Context, client *http.Client, appPageURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, appPageURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Referer", appPageURL) + req.Header.Set("User-Agent", schoolUserAgent) + + res, err := client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + return "", responseError(res) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + match := graduateYJS1UserIDPattern.FindSubmatch(body) + if len(match) < 2 { + return "", fmt.Errorf("could not resolve graduate student ID from %s", appPageURL) + } + return string(match[1]), nil +} + +func pickGraduateYJS1Term(terms []graduateYJS1Term, semesterID int) (Semester, error) { + semesters := graduateYJS1SemestersFromTerms(terms) + if len(semesters) == 0 { + return Semester{}, fmt.Errorf("no graduate semesters available") + } + if semesterID == 0 { + for i, term := range terms { + if term.SFDQXQ == "1" { + return semesters[i], nil + } + } + return semesters[0], nil + } + for _, semester := range semesters { + if semester.ID == semesterID { + return semester, nil + } + } + return Semester{}, fmt.Errorf("graduate semester %d was not found", semesterID) +} + +func graduateYJS1SemestersFromTerms(rows []graduateYJS1Term) []Semester { + semesters := make([]Semester, 0, len(rows)) + for _, row := range rows { + id, _ := strconv.Atoi(row.DM) + name := firstNonEmpty(row.MC, graduateYJS1SemesterNameFromCode(row.DM)) + semesters = append(semesters, Semester{ + ID: id, + Code: row.DM, + SemesterCn: name, + SemesterEn: name, + StartDate: row.QSSJ, + EndDate: row.JZSJ, + IsLast: row.SFDQXQ == "1", + }) + } + return semesters +} + +func graduateYJS1SemesterNameFromCode(code string) string { + if len(code) < 5 { + return code + } + year, err := strconv.Atoi(code[:4]) + if err != nil { + return code + } + switch code[4:] { + case "1": + return fmt.Sprintf("%d年秋季学期", year) + case "2": + return fmt.Sprintf("%d年春季学期", year+1) + case "3": + return fmt.Sprintf("%d年夏季学期", year+1) + default: + return code + } +} + +func graduateYJS1CurriculumItemsFromRows(semester Semester, rows []graduateYJS1ScheduleRow) []CurriculumItem { + items := make([]CurriculumItem, 0, len(rows)) + for _, row := range rows { + items = append(items, CurriculumItem{ + LessonID: parseLeadingInt(row.WID), + SemesterID: semester.ID, + LessonCode: compactWhitespace(firstNonEmpty(row.BJMC, row.WID)), + CourseCode: compactWhitespace(row.KCDM), + CourseName: compactWhitespace(row.KCMC), + Teachers: splitTeachers(row.RKJS), + Schedule: graduateYJS1ScheduleString(row.PKSJDD, row.ZCMC), + }) + } + SortCurriculum(items) + return items +} + +func graduateYJS1ScheduleString(lessonSchedule, weekRanges string) string { + lessonSchedule = compactWhitespace(lessonSchedule) + weekRanges = compactWhitespace(strings.ReplaceAll(weekRanges, "~", "-")) + if lessonSchedule == "" { + return weekRanges + } + if weekRanges == "" { + return lessonSchedule + } + + scheduleParts := splitSemicolonParts(lessonSchedule) + weekParts := splitSemicolonParts(weekRanges) + if len(scheduleParts) == len(weekParts) { + parts := make([]string, 0, len(scheduleParts)) + for i, schedule := range scheduleParts { + if week := weekParts[i]; week != "" { + parts = append(parts, schedule+" ["+week+"周]") + continue + } + parts = append(parts, schedule) + } + return strings.Join(parts, "; ") + } + + return lessonSchedule + " [" + weekRanges + "周]" +} + +func graduateYJS1ScoreItemsFromRows(rows []graduateYJS1ScoreRow) []ScoreItem { + items := make([]ScoreItem, 0, len(rows)) + for _, row := range rows { + semesterID, _ := strconv.Atoi(row.XNXQDM) + items = append(items, ScoreItem{ + SemesterID: semesterID, + SemesterName: compactWhitespace(firstNonEmpty(row.XNXQDMDisplay, graduateYJS1SemesterNameFromCode(row.XNXQDM))), + CourseName: compactWhitespace(row.KCMC), + LessonCode: compactWhitespace(firstNonEmpty(row.BJMC, row.KCDM)), + CourseCode: compactWhitespace(row.KCDM), + Credits: graduateYJS1Float(row.XF), + GradePoint: graduateYJS1Float(row.JDZ), + Score: graduateYJS1String(row.CJ), + GradeText: graduateYJS1Join(" / ", row.CJFZDMDisplay, row.CJJLDisplay), + }) + } + return items +} + +func firstGraduateYJS1ScoreInfoRow(rows []graduateYJS1ScoreInfoRow) graduateYJS1ScoreInfoRow { + if len(rows) == 0 { + return graduateYJS1ScoreInfoRow{} + } + return rows[0] +} + +func buildGraduateYJS1ScoreSummary(info graduateYJS1ScoreInfoRow, profile graduateYJS1ScoreProfile) json.RawMessage { + summary := map[string]any{} + if studentID := compactWhitespace(firstNonEmpty(profile.XH, info.XH)); studentID != "" { + summary["studentId"] = studentID + } + if program := compactWhitespace(firstNonEmpty(profile.XSCJXX.PYCCDMDisplay, info.PYCCDMDisplay)); program != "" { + summary["program"] = program + } + if major := compactWhitespace(firstNonEmpty(profile.XSCJXX.ZYDMDisplay, info.ZYDMDisplay)); major != "" { + summary["major"] = major + } + if department := compactWhitespace(firstNonEmpty(profile.XSCJXX.YXDMDisplay, info.YXDMDisplay)); department != "" { + summary["department"] = department + } + if advisor := compactWhitespace(firstNonEmpty(profile.XSCJXX.DSXM, info.DSXM)); advisor != "" { + summary["advisor"] = advisor + } + if grade := compactWhitespace(firstNonEmpty(profile.XSCJXX.NJDMDisplay, info.NJDMDisplay)); grade != "" { + summary["grade"] = grade + } + if status := compactWhitespace(firstNonEmpty(profile.XSCJXX.ZCZTDisplay, info.ZCZTDisplay)); status != "" { + summary["status"] = status + } + if graduation := compactWhitespace(firstNonEmpty(profile.XSCJXX.YJBYSJ, info.YJBYSJ)); graduation != "" { + summary["expectedGraduation"] = graduation + } + if gpa := strings.TrimSpace(profile.XSCJXX.PJJDZ); gpa != "" { + if value, err := strconv.ParseFloat(gpa, 64); err == nil { + summary["averageGradePoint"] = value + } else { + summary["averageGradePoint"] = gpa + } + } + if len(summary) == 0 { + return nil + } + data, err := json.Marshal(summary) + if err != nil { + return nil + } + return data +} + +func graduateYJS1ExamItemsFromRows(rows []graduateYJS1ExamRow) []ExamItem { + items := make([]ExamItem, 0, len(rows)) + for _, row := range rows { + dateTime := compactWhitespace(strings.Trim(strings.Join([]string{ + strings.TrimSpace(row.KSKSSJ), + strings.TrimSpace(row.KSJSSJ), + }, " - "), " -")) + items = append(items, ExamItem{ + CourseName: compactWhitespace(row.KCMC), + LessonCode: compactWhitespace(firstNonEmpty(row.BJMC, row.KCDM)), + DateTime: dateTime, + Location: compactWhitespace(row.KSDD), + ExamType: compactWhitespace(firstNonEmpty(row.KSXSDisplay, row.KSXS)), + Status: compactWhitespace(firstNonEmpty(row.SBZTDisplay, row.SBZT)), + Seat: compactWhitespace(row.ZWH), + }) + } + SortExams(items) + return items +} + +func graduateYJS1HomeworkItemsFromRows(rows []graduateYJS1HomeworkRow, now time.Time) []HomeworkItem { + items := make([]HomeworkItem, 0, len(rows)) + for _, row := range rows { + items = append(items, HomeworkItem{ + ID: firstNonEmpty(row.XSZYWID, row.WID), + Title: compactWhitespace(row.ZYMC), + CourseName: compactWhitespace(row.KCMC), + StartAt: compactWhitespace(row.ZYKSSJ), + EndAt: compactWhitespace(row.ZYJZSJ), + Status: graduateYJS1HomeworkStatus(row, now), + }) + } + SortHomework(items) + return items +} + +func graduateYJS1HomeworkStatus(row graduateYJS1HomeworkRow, now time.Time) string { + if graduateYJS1String(row.CJ) != "" { + return "graded" + } + if strings.TrimSpace(row.XSZYWID) != "" || strings.TrimSpace(row.ZYSCSJ) != "" { + return "submitted" + } + if deadline, ok := graduateYJS1ParseTime(row.ZYJZSJ); ok && now.After(deadline) { + return "overdue" + } + if start, ok := graduateYJS1ParseTime(row.ZYKSSJ); ok && now.Before(start) { + return "not started" + } + return "pending" +} + +func graduateYJS1ParseTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02"} { + if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func graduateYJS1String(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return compactWhitespace(v) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case json.Number: + return v.String() + default: + return compactWhitespace(fmt.Sprint(v)) + } +} + +func graduateYJS1Float(value any) float64 { + switch v := value.(type) { + case nil: + return 0 + case float64: + return v + case json.Number: + f, _ := v.Float64() + return f + case string: + f, _ := strconv.ParseFloat(strings.TrimSpace(v), 64) + return f + default: + f, _ := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(v)), 64) + return f + } +} + +func graduateYJS1Join(sep string, values ...string) string { + parts := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + value = compactWhitespace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + parts = append(parts, value) + } + return strings.Join(parts, sep) +} + +func splitSemicolonParts(value string) []string { + rawParts := strings.Split(value, ";") + parts := make([]string, 0, len(rawParts)) + for _, part := range rawParts { + part = compactWhitespace(part) + if part != "" { + parts = append(parts, part) + } + } + return parts +} diff --git a/internal/school/graduate_test.go b/internal/school/graduate_test.go new file mode 100644 index 0000000..ac1db2a --- /dev/null +++ b/internal/school/graduate_test.go @@ -0,0 +1,135 @@ +package school + +import ( + "reflect" + "testing" + "time" +) + +func TestGraduateYJS1SemestersFromTermsMarksCurrent(t *testing.T) { + t.Parallel() + + items := graduateYJS1SemestersFromTerms([]graduateYJS1Term{ + {DM: "20251", MC: "2025年秋季学期", SFDQXQ: "0", QSSJ: "2025-09-01", JZSJ: "2026-01-20"}, + {DM: "20252", MC: "2026年春季学期", SFDQXQ: "1", QSSJ: "2026-02-23", JZSJ: "2026-07-05"}, + }) + + if len(items) != 2 { + t.Fatalf("expected 2 semesters, got %d", len(items)) + } + if items[0].IsLast { + t.Fatal("first semester should not be current") + } + if !items[1].IsLast { + t.Fatal("second semester should be current") + } +} + +func TestGraduateYJS1CurriculumItemsFromRows(t *testing.T) { + t.Parallel() + + items := graduateYJS1CurriculumItemsFromRows(Semester{ID: 20252}, []graduateYJS1ScheduleRow{ + { + WID: "abc-row", + BJMC: "SA25113014.01", + KCDM: "Y0203220", + KCMC: "软件体系结构分析与设计", + PKSJDD: "周一 3-4节 软件楼101; 周三 7-8节 软件楼102", + RKJS: "张三、李四", + ZCMC: "1~8; 9~16", + }, + }) + + want := []CurriculumItem{ + { + SemesterID: 20252, + LessonCode: "SA25113014.01", + CourseCode: "Y0203220", + CourseName: "软件体系结构分析与设计", + Teachers: []string{"张三", "李四"}, + Schedule: "周一 3-4节 软件楼101 [1-8周]; 周三 7-8节 软件楼102 [9-16周]", + }, + } + if !reflect.DeepEqual(items, want) { + t.Fatalf("graduateYJS1CurriculumItemsFromRows() = %#v, want %#v", items, want) + } +} + +func TestGraduateYJS1ScoreItemsFromRows(t *testing.T) { + t.Parallel() + + items := graduateYJS1ScoreItemsFromRows([]graduateYJS1ScoreRow{ + { + XNXQDM: "20252", + XNXQDMDisplay: "2026年春季学期", + BJMC: "SA25113014.01", + KCDM: "Y0203220", + KCMC: "软件体系结构分析与设计", + CJFZDMDisplay: "百分制", + CJJLDisplay: "正常考试", + XF: 2.0, + CJ: 87.0, + JDZ: 3.7, + }, + }) + + if len(items) != 1 { + t.Fatalf("expected 1 score item, got %d", len(items)) + } + if items[0].SemesterID != 20252 { + t.Fatalf("unexpected semester ID: %d", items[0].SemesterID) + } + if items[0].Score != "87" { + t.Fatalf("unexpected score string: %q", items[0].Score) + } + if items[0].GradeText != "百分制 / 正常考试" { + t.Fatalf("unexpected grade text: %q", items[0].GradeText) + } + if items[0].Credits != 2 || items[0].GradePoint != 3.7 { + t.Fatalf("unexpected numeric mapping: credits=%v gradePoint=%v", items[0].Credits, items[0].GradePoint) + } +} + +func TestGraduateYJS1HomeworkItemsFromRows(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.Local) + items := graduateYJS1HomeworkItemsFromRows([]graduateYJS1HomeworkRow{ + { + WID: "1", + KCMC: "课程A", + ZYMC: "作业A", + ZYKSSJ: "2026-03-01 10:00:00", + ZYJZSJ: "2026-03-09 23:59:00", + }, + { + WID: "2", + XSZYWID: "sub-2", + KCMC: "课程B", + ZYMC: "作业B", + ZYKSSJ: "2026-03-01 10:00:00", + ZYJZSJ: "2026-03-20 23:59:00", + }, + { + WID: "3", + KCMC: "课程C", + ZYMC: "作业C", + ZYKSSJ: "2026-03-01 10:00:00", + ZYJZSJ: "2026-03-20 23:59:00", + CJ: 95.0, + }, + }, now) + + if len(items) != 3 { + t.Fatalf("expected 3 homework items, got %d", len(items)) + } + if items[0].Status != "overdue" { + t.Fatalf("expected overdue homework first, got %q", items[0].Status) + } + if items[1].Status != "submitted" { + t.Fatalf("expected submitted homework second after sorting by deadline, got %q", items[1].Status) + } + if items[2].Status != "graded" { + t.Fatalf("expected graded homework last, got %q", items[2].Status) + } +} diff --git a/internal/school/models.go b/internal/school/models.go new file mode 100644 index 0000000..d337e78 --- /dev/null +++ b/internal/school/models.go @@ -0,0 +1,138 @@ +package school + +import ( + "encoding/json" + "sort" + "strings" +) + +type Credentials struct { + Username string + Password string + TOTP string +} + +type Semester struct { + ID int `json:"id"` + Code string `json:"code,omitempty"` + SemesterCn string `json:"semesterCn,omitempty"` + SemesterEn string `json:"semesterEn,omitempty"` + StartDate string `json:"startDate,omitempty"` + EndDate string `json:"endDate,omitempty"` + IsLast bool `json:"isLast,omitempty"` +} + +func (s Semester) Name() string { + if s.SemesterCn != "" { + return s.SemesterCn + } + if s.SemesterEn != "" { + return s.SemesterEn + } + if s.Code != "" { + return s.Code + } + return "" +} + +type CurriculumItem struct { + SemesterID int `json:"semesterId"` + LessonID int `json:"lessonId,omitempty"` + LessonCode string `json:"lessonCode,omitempty"` + CourseCode string `json:"courseCode,omitempty"` + CourseName string `json:"courseName,omitempty"` + Credits float64 `json:"credits,omitempty"` + Teachers []string `json:"teachers,omitempty"` + Schedule string `json:"schedule,omitempty"` +} + +func (i CurriculumItem) TeacherList() string { + return strings.Join(i.Teachers, ", ") +} + +type ExamItem struct { + CourseName string `json:"courseName,omitempty"` + LessonCode string `json:"lessonCode,omitempty"` + ExamType string `json:"examType,omitempty"` + DateTime string `json:"dateTime,omitempty"` + Location string `json:"location,omitempty"` + Seat string `json:"seat,omitempty"` + Status string `json:"status,omitempty"` +} + +type ScoreReport struct { + Summary json.RawMessage `json:"summary,omitempty"` + Items []ScoreItem `json:"items"` +} + +type ScoreItem struct { + SemesterID int `json:"semesterId"` + SemesterName string `json:"semesterName,omitempty"` + CourseName string `json:"courseName,omitempty"` + LessonCode string `json:"lessonCode,omitempty"` + CourseCode string `json:"courseCode,omitempty"` + Credits float64 `json:"credits,omitempty"` + GradePoint float64 `json:"gradePoint,omitempty"` + Score string `json:"score,omitempty"` + GradeText string `json:"gradeText,omitempty"` +} + +type HomeworkItem struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + CourseName string `json:"courseName,omitempty"` + LessonCode string `json:"lessonCode,omitempty"` + SemesterCode string `json:"semesterCode,omitempty"` + ExternalItemID string `json:"externalItemId,omitempty"` + StartAt string `json:"startAt,omitempty"` + EndAt string `json:"endAt,omitempty"` + Status string `json:"status,omitempty"` +} + +func SortCurriculum(items []CurriculumItem) { + sort.Slice(items, func(i, j int) bool { + if items[i].LessonCode != items[j].LessonCode { + return items[i].LessonCode < items[j].LessonCode + } + if items[i].CourseName != items[j].CourseName { + return items[i].CourseName < items[j].CourseName + } + return items[i].LessonID < items[j].LessonID + }) +} + +func SortExams(items []ExamItem) { + sort.Slice(items, func(i, j int) bool { + if items[i].DateTime != items[j].DateTime { + return items[i].DateTime < items[j].DateTime + } + if items[i].CourseName != items[j].CourseName { + return items[i].CourseName < items[j].CourseName + } + return items[i].LessonCode < items[j].LessonCode + }) +} + +func SortScores(items []ScoreItem) { + sort.Slice(items, func(i, j int) bool { + if items[i].SemesterID != items[j].SemesterID { + return items[i].SemesterID > items[j].SemesterID + } + if items[i].CourseName != items[j].CourseName { + return items[i].CourseName < items[j].CourseName + } + return items[i].LessonCode < items[j].LessonCode + }) +} + +func SortHomework(items []HomeworkItem) { + sort.Slice(items, func(i, j int) bool { + if items[i].EndAt != items[j].EndAt { + return items[i].EndAt < items[j].EndAt + } + if items[i].CourseName != items[j].CourseName { + return items[i].CourseName < items[j].CourseName + } + return items[i].Title < items[j].Title + }) +} diff --git a/internal/school/program.go b/internal/school/program.go new file mode 100644 index 0000000..9e6d31e --- /dev/null +++ b/internal/school/program.go @@ -0,0 +1,12 @@ +package school + +type Program string + +const ( + ProgramUndergraduate Program = "undergraduate" + ProgramGraduate Program = "graduate" +) + +func (p Program) IsGraduate() bool { + return p == ProgramGraduate +} diff --git a/internal/school/score_parser_test.go b/internal/school/score_parser_test.go new file mode 100644 index 0000000..0667957 --- /dev/null +++ b/internal/school/score_parser_test.go @@ -0,0 +1,78 @@ +package school + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestParseScoreReportHTML(t *testing.T) { + const page = ` + +
2021年秋季学期2024年秋季学期平均学分绩点(GPA)为 3.01,排名情况:
全校2021年级信息与计算科学专业 GPA 排名 63/90
+
+
+
    +
  • 总学分160
  • +
  • 已获学分160
  • +
  • 不及格学分0
  • +
  • GPA3.08
  • +
  • 加权平均分81.05
  • +
  • 算术平均分81.68
  • +
+
+
+

2025年春季学期

+ + +
毕业论文THESIS32084.3A+
+
+
+

2024年秋季学期

+ + + +
芯片科技概论IC19014024.397
思想政治理论课实践MARX1005802通过
+
+
+
+
+
+` + + report, err := parseScoreReportHTML(strings.NewReader(page)) + if err != nil { + t.Fatalf("parseScoreReportHTML returned error: %v", err) + } + + if len(report.Items) != 3 { + t.Fatalf("expected 3 score items, got %d", len(report.Items)) + } + if report.Items[0].SemesterID != 381 || report.Items[0].CourseName != "毕业论文" || report.Items[0].CourseCode != "THESIS" { + t.Fatalf("unexpected first item: %+v", report.Items[0]) + } + if report.Items[1].SemesterID != 362 || report.Items[1].CourseName != "思想政治理论课实践" || report.Items[1].Score != "通过" { + t.Fatalf("unexpected second item: %+v", report.Items[1]) + } + if report.Items[1].GradePoint != 0 { + t.Fatalf("expected empty grade point to parse as zero, got %v", report.Items[1].GradePoint) + } + + var summary map[string]any + if err := json.Unmarshal(report.Summary, &summary); err != nil { + t.Fatalf("unmarshal summary: %v", err) + } + if summary["fromSemester"] != "2021年秋季学期" || summary["toSemester"] != "2024年秋季学期" { + t.Fatalf("unexpected summary semesters: %+v", summary) + } + if summary["ranking"] != "全校2021年级信息与计算科学专业 GPA 排名 63 /90" && summary["ranking"] != "全校2021年级信息与计算科学专业 GPA 排名 63/90" { + t.Fatalf("unexpected ranking summary: %+v", summary["ranking"]) + } + if summary["gpa"] != 3.08 { + t.Fatalf("unexpected gpa summary: %+v", summary["gpa"]) + } +} diff --git a/internal/school/totp.go b/internal/school/totp.go new file mode 100644 index 0000000..f876d8e --- /dev/null +++ b/internal/school/totp.go @@ -0,0 +1,121 @@ +package school + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base32" + "encoding/binary" + "fmt" + "hash" + "net/url" + "strconv" + "strings" + "time" +) + +func CurrentOTP(raw string, now time.Time) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", fmt.Errorf("empty totp value") + } + if len(value) == 6 && isDigits(value) { + return value, nil + } + + secret, algorithm, digits, period, err := parseOTPValue(value) + if err != nil { + return "", err + } + + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + if err != nil { + return "", fmt.Errorf("decode totp secret: %w", err) + } + + counter := uint64(now.Unix() / int64(period)) + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + + mac := hmac.New(hashForAlgorithm(algorithm), key) + _, _ = mac.Write(buf) + sum := mac.Sum(nil) + offset := sum[len(sum)-1] & 0x0f + code := (int(sum[offset])&0x7f)<<24 | + (int(sum[offset+1])&0xff)<<16 | + (int(sum[offset+2])&0xff)<<8 | + (int(sum[offset+3]) & 0xff) + + mod := 1 + for range digits { + mod *= 10 + } + format := "%0" + strconv.Itoa(digits) + "d" + return fmt.Sprintf(format, code%mod), nil +} + +func parseOTPValue(raw string) (secret, algorithm string, digits, period int, err error) { + algorithm = "SHA1" + digits = 6 + period = 30 + + if strings.HasPrefix(raw, "otpauth://") { + parsed, parseErr := url.Parse(raw) + if parseErr != nil { + return "", "", 0, 0, fmt.Errorf("parse otpauth url: %w", parseErr) + } + query := parsed.Query() + secret = strings.ToUpper(strings.TrimSpace(query.Get("secret"))) + if value := strings.TrimSpace(query.Get("algorithm")); value != "" { + algorithm = strings.ToUpper(value) + } + if value := strings.TrimSpace(query.Get("digits")); value != "" { + digits, err = strconv.Atoi(value) + if err != nil { + return "", "", 0, 0, fmt.Errorf("parse otp digits: %w", err) + } + } + if value := strings.TrimSpace(query.Get("period")); value != "" { + period, err = strconv.Atoi(value) + if err != nil { + return "", "", 0, 0, fmt.Errorf("parse otp period: %w", err) + } + } + } else { + secret = strings.ToUpper(strings.TrimSpace(raw)) + } + + secret = strings.NewReplacer(" ", "", "-", "").Replace(secret) + if secret == "" { + return "", "", 0, 0, fmt.Errorf("missing totp secret") + } + if digits <= 0 { + return "", "", 0, 0, fmt.Errorf("invalid otp digits: %d", digits) + } + if period <= 0 { + return "", "", 0, 0, fmt.Errorf("invalid otp period: %d", period) + } + + return secret, algorithm, digits, period, nil +} + +func hashForAlgorithm(name string) func() hash.Hash { + switch strings.ToUpper(name) { + case "SHA256": + return sha256.New + case "SHA512": + return sha512.New + default: + return sha1.New + } +} + +func isDigits(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} From 4bb84faeb20e089665da31d541464a0a4a9e1b0a Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Mon, 1 Jun 2026 11:58:16 +0800 Subject: [PATCH 3/6] Add school commands and Life@USTC sync --- README.md | 28 + internal/cmd/root/root.go | 2 + internal/cmd/root/root_test.go | 2 + internal/cmd/school/school.go | 1469 ++++++++++++++++++++++++++++ internal/cmd/school/school_test.go | 167 ++++ 5 files changed, 1668 insertions(+) create mode 100644 internal/cmd/school/school.go create mode 100644 internal/cmd/school/school_test.go diff --git a/README.md b/README.md index e060fde..075702c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,18 @@ life-ustc semester current life-ustc bus query --from east --to west life-ustc metadata +# Official USTC sources +life-ustc school semesters +life-ustc school semesters --graduate +life-ustc school curriculum --semester-id +life-ustc school curriculum --graduate +life-ustc school exam +life-ustc school score +life-ustc school homework +life-ustc school sync --dry-run +life-ustc school sync --graduate --dry-run +life-ustc school sync + # Community features life-ustc comment list --target-type section --target-id life-ustc comment create --target-type section --target-id --body "Great class!" @@ -88,9 +100,24 @@ life-ustc admin suspension create --user-id --reason "spam" - `me` is identity and account status. - Personal resources live at the top level: `todo`, `homework`, `calendar`, `upload`. - Browseable campus resources also live at the top level: `course`, `section`, `teacher`, `semester`, `schedule`, `bus`. +- `school` groups official USTC integrations: `semesters`, `curriculum`, `exam`, `score`, `homework`, and `sync`. - Generic cross-resource workflows are available via `comment`, `description`, and `api`. - Commands that benefit from guided input open their own TUI by default in an interactive terminal when no list/filter flags are provided, such as `course`, `section`, and `teacher`; use `--no-interactive` to force plain table output. +## Official USTC Sources + +- `life-ustc school semesters` reads undergraduate semesters from `catalog.ustc.edu.cn`, or graduate semesters from the official `yjs1.ustc.edu.cn` graduate apps with `--graduate`. +- `life-ustc school curriculum`, `exam`, and `score` sign in directly from Go without a browser backend. Undergraduate data comes from `jw.ustc.edu.cn`; graduate data comes from the official `yjs1.ustc.edu.cn` graduate apps with `--graduate`. +- `life-ustc school homework` reads Blackboard calendar/homework data from `www.bb.ustc.edu.cn`, or graduate homework from the official `yjs1.ustc.edu.cn` graduate apps with `--graduate`. +- `life-ustc school sync` reads lesson codes from the active school system, matches them to Life@USTC sections, and updates your Life@USTC calendar subscriptions. The sync is one-way to Life@USTC only; it does not write back to USTC systems. Use `--dry-run` to preview matches without updating subscriptions. + +Authenticated `school` commands accept `--username`, `--password`, `--totp`, `--undergraduate`, `--graduate`, and sync commands also accept `--all-programs`. If omitted, the CLI falls back to the configured school program list, then program-specific credentials, then undergraduate. Persist defaults with `life-ustc config set-school-programs undergraduate,graduate`. + +- Undergraduate username: `PASSPORT_UNDERGRADUATE_USERNAME` +- Graduate username: `PASSPORT_GRADUATE_USERNAME` +- Password: `PASSPORT_PASSWORD` +- TOTP: `PASSPORT_TOTP` + ## JSON output All commands support `--format json` or `--json` for machine-readable output: @@ -146,6 +173,7 @@ life-ustc api -i metadata - Config directory: `~/.config/life-ustc/` (or `$XDG_CONFIG_HOME/life-ustc/`) - Override server per-command: `--server URL` - Environment variable: `LIFE_USTC_SERVER` +- School program default: `life-ustc config set-school-programs undergraduate,graduate` ## Global Options diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 9893c7b..441fac0 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -21,6 +21,7 @@ import ( "github.com/Life-USTC/CLI/internal/cmd/me" "github.com/Life-USTC/CLI/internal/cmd/metadata" "github.com/Life-USTC/CLI/internal/cmd/schedule" + schoolcmd "github.com/Life-USTC/CLI/internal/cmd/school" "github.com/Life-USTC/CLI/internal/cmd/section" "github.com/Life-USTC/CLI/internal/cmd/semester" "github.com/Life-USTC/CLI/internal/cmd/teacher" @@ -141,6 +142,7 @@ scripting, or drop down to 'life-ustc api' for raw endpoint access.`, grouped(groupBrowse, semester.NewCmdSemester()), grouped(groupBrowse, schedule.NewCmdSchedule()), grouped(groupBrowse, bus.NewCmdBus()), + grouped(groupBrowse, schoolcmd.NewCmdSchool()), grouped(groupCommunity, comment.NewCmdComment()), grouped(groupCommunity, description.NewCmdDescription()), diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index 6cf7b34..6f10ac1 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -89,6 +89,7 @@ func TestAllExpectedCommandsPresent(t *testing.T) { "auth", "me", "todo", "homework", "calendar", "upload", "course", "section", "teacher", "semester", "schedule", "bus", + "school", "comment", "description", "metadata", "admin", @@ -116,6 +117,7 @@ func TestCommandGroupAssignments(t *testing.T) { "semester": groupBrowse, "schedule": groupBrowse, "bus": groupBrowse, + "school": groupBrowse, "comment": groupCommunity, "description": groupCommunity, "metadata": groupRef, diff --git a/internal/cmd/school/school.go b/internal/cmd/school/school.go new file mode 100644 index 0000000..5413135 --- /dev/null +++ b/internal/cmd/school/school.go @@ -0,0 +1,1469 @@ +package school + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/Life-USTC/CLI/internal/api" + "github.com/Life-USTC/CLI/internal/cmd/cmdutil" + "github.com/Life-USTC/CLI/internal/config" + "github.com/Life-USTC/CLI/internal/openapi" + "github.com/Life-USTC/CLI/internal/output" + ustcschool "github.com/Life-USTC/CLI/internal/school" +) + +type authFlags struct { + username string + password string + totp string + undergraduate bool + graduate bool +} + +var schoolTimeLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +var schoolDebug bool + +type schoolCurriculumResult struct { + Program ustcschool.Program `json:"program"` + Semester ustcschool.Semester `json:"semester"` + Items []ustcschool.CurriculumItem `json:"items"` +} + +type schoolSyncSource struct { + Program ustcschool.Program + Client *ustcschool.Client +} + +func NewCmdSchool() *cobra.Command { + cmd := &cobra.Command{ + Use: "school", + Short: "Read data from official USTC sites", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.PersistentFlags().BoolVar(&schoolDebug, "debug", false, "Print school fetch and sync timing details to stderr") + + cmd.AddCommand( + newCmdSchoolSemesters(), + newCmdSchoolCurriculum(), + newCmdSchoolExam(), + newCmdSchoolScore(), + newCmdSchoolHomework(), + newCmdSchoolSync(), + ) + + return cmd +} + +func newCmdSchoolSemesters() *cobra.Command { + var auth authFlags + + cmd := &cobra.Command{ + Use: "semesters", + Short: "List semesters from official USTC systems", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + defer debugStep("school semesters")() + client, err := newSchoolClient(auth) + if err != nil { + return err + } + semesters, err := client.FetchSemesters(cmd.Context()) + if err != nil { + return err + } + + rows := make([]map[string]any, 0, len(semesters)) + for _, semester := range semesters { + rows = append(rows, map[string]any{ + "id": semester.ID, + "code": semester.Code, + "name": semester.Name(), + "startDate": semester.StartDate, + "endDate": semester.EndDate, + "isCurrent": semester.IsLast, + }) + } + + return output.OutputList( + semesters, + rows, + []output.Column{ + {Key: "id", Header: "ID"}, + {Key: "code", Header: "Code"}, + {Key: "name", Header: "Semester"}, + {Key: "startDate", Header: "Start"}, + {Key: "endDate", Header: "End"}, + {Key: "isCurrent", Header: "Current"}, + }, + 0, + 0, + ) + }, + } + + addAuthFlags(cmd, &auth) + return cmd +} + +func newCmdSchoolCurriculum() *cobra.Command { + var auth authFlags + var semesterID int + + cmd := &cobra.Command{ + Use: "curriculum", + Short: "Read your current USTC curriculum from jw.ustc.edu.cn", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + defer debugStep("school curriculum")() + client, err := newSchoolClient(auth) + if err != nil { + return err + } + semester, items, err := client.FetchCurriculum(cmd.Context(), semesterID) + if err != nil { + return err + } + + rows := make([]map[string]any, 0, len(items)) + for _, item := range items { + rows = append(rows, map[string]any{ + "lessonCode": item.LessonCode, + "courseCode": item.CourseCode, + "course": item.CourseName, + "credits": item.Credits, + "teachers": item.TeacherList(), + "schedule": item.Schedule, + }) + } + + if !output.IsJSON() { + output.KVWithTitle([]output.KVPair{ + {Key: "ID", Value: semester.ID}, + {Key: "Name", Value: semester.Name()}, + {Key: "Code", Value: semester.Code, SkipEmpty: true}, + }, "Semester") + } + + return output.OutputList( + map[string]any{"semester": semester, "items": items}, + rows, + []output.Column{ + {Key: "lessonCode", Header: "Lesson Code"}, + {Key: "courseCode", Header: "Course Code"}, + {Key: "course", Header: "Course"}, + {Key: "credits", Header: "Credits"}, + {Key: "teachers", Header: "Teachers"}, + {Key: "schedule", Header: "Schedule"}, + }, + 0, + 0, + ) + }, + } + + addAuthFlags(cmd, &auth) + cmd.Flags().IntVar(&semesterID, "semester-id", 0, "Catalog semester ID (defaults to current semester)") + return cmd +} + +func newCmdSchoolExam() *cobra.Command { + var auth authFlags + var semesterID int + + cmd := &cobra.Command{ + Use: "exam", + Short: "Read your exam arrangements from jw.ustc.edu.cn", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + defer debugStep("school exam")() + client, err := newSchoolClient(auth) + if err != nil { + return err + } + items, err := client.FetchExams(cmd.Context(), semesterID) + if err != nil { + return err + } + + rows := make([]map[string]any, 0, len(items)) + for _, item := range items { + rows = append(rows, map[string]any{ + "course": item.CourseName, + "lessonCode": item.LessonCode, + "type": item.ExamType, + "datetime": item.DateTime, + "location": item.Location, + "seat": item.Seat, + "status": item.Status, + }) + } + + return output.OutputList( + items, + rows, + []output.Column{ + {Key: "course", Header: "Course"}, + {Key: "lessonCode", Header: "Lesson Code"}, + {Key: "type", Header: "Type"}, + {Key: "datetime", Header: "Date / Time"}, + {Key: "location", Header: "Location"}, + {Key: "seat", Header: "Seat"}, + {Key: "status", Header: "Status"}, + }, + 0, + 0, + ) + }, + } + + addAuthFlags(cmd, &auth) + cmd.Flags().IntVar(&semesterID, "semester-id", 0, "Graduate semester ID (defaults to current semester)") + return cmd +} + +func newCmdSchoolScore() *cobra.Command { + var auth authFlags + + cmd := &cobra.Command{ + Use: "score", + Short: "Read your scores from jw.ustc.edu.cn", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + defer debugStep("school score")() + client, err := newSchoolClient(auth) + if err != nil { + return err + } + report, err := client.FetchScores(cmd.Context()) + if err != nil { + return err + } + + rows := make([]map[string]any, 0, len(report.Items)) + for _, item := range report.Items { + rows = append(rows, map[string]any{ + "semester": firstNonEmpty(item.SemesterName, strconv.Itoa(item.SemesterID)), + "lessonCode": item.LessonCode, + "courseCode": item.CourseCode, + "course": item.CourseName, + "credits": item.Credits, + "gp": item.GradePoint, + "score": item.Score, + "gradeText": item.GradeText, + }) + } + + if !output.IsJSON() && len(report.Summary) > 0 { + var summary map[string]any + if err := json.Unmarshal(report.Summary, &summary); err == nil { + pairs := make([]output.KVPair, 0, len(summary)) + for key, value := range summary { + pairs = append(pairs, output.KVPair{Key: key, Value: value, SkipEmpty: true}) + } + output.KVWithTitle(pairs, "Summary") + } + } + + return output.OutputList( + report, + rows, + []output.Column{ + {Key: "semester", Header: "Semester"}, + {Key: "lessonCode", Header: "Lesson Code"}, + {Key: "courseCode", Header: "Course Code"}, + {Key: "course", Header: "Course"}, + {Key: "credits", Header: "Credits"}, + {Key: "gp", Header: "GP"}, + {Key: "score", Header: "Score"}, + {Key: "gradeText", Header: "Detail"}, + }, + 0, + 0, + ) + }, + } + + addAuthFlags(cmd, &auth) + return cmd +} + +func newCmdSchoolHomework() *cobra.Command { + var auth authFlags + + cmd := &cobra.Command{ + Use: "homework [command]", + Short: "Read Blackboard homework and calendar items", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + defer debugStep("school homework")() + client, err := newSchoolClient(auth) + if err != nil { + return err + } + items, err := client.FetchHomework(cmd.Context()) + if err != nil { + return err + } + + rows := make([]map[string]any, 0, len(items)) + for _, item := range items { + rows = append(rows, map[string]any{ + "course": item.CourseName, + "lessonCode": item.LessonCode, + "title": item.Title, + "startAt": item.StartAt, + "endAt": item.EndAt, + "status": item.Status, + }) + } + + return output.OutputList( + items, + rows, + []output.Column{ + {Key: "course", Header: "Course"}, + {Key: "lessonCode", Header: "Lesson Code"}, + {Key: "title", Header: "Title"}, + {Key: "startAt", Header: "Start"}, + {Key: "endAt", Header: "End"}, + {Key: "status", Header: "Status"}, + }, + 0, + 0, + ) + }, + } + + addAuthFlags(cmd, &auth) + cmd.AddCommand(newCmdSchoolHomeworkSync()) + return cmd +} + +func newCmdSchoolHomeworkSync() *cobra.Command { + var auth authFlags + var allPrograms bool + var semesterID int + var dryRun bool + + cmd := &cobra.Command{ + Use: "sync", + Short: "Sync school homework to Life@USTC", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + defer debugStep("school homework sync")() + if allPrograms && semesterID != 0 { + return fmt.Errorf("--semester-id cannot be used with --all-programs") + } + var sources []schoolSyncSource + if err := withDebugStep("resolve school sync sources", func() error { + var err error + sources, err = newSchoolSyncSources(auth, allPrograms) + return err + }); err != nil { + return err + } + var apiClient *api.TypedClient + if err := withDebugStep("create Life@USTC API client", func() error { + var err error + apiClient, err = api.NewTypedClient(cmdutil.ServerFromCmd(cmd), true) + return err + }); err != nil { + return err + } + var lifeSemesterRaw any + if err := withDebugStep("Life@USTC list semesters", func() error { + var err error + lifeSemesterRaw, err = api.ParseResponseRaw(apiClient.ListSemesters(cmd.Context(), &openapi.ListSemestersParams{ + Limit: stringPtr("200"), + })) + return err + }); err != nil { + return err + } + + var results []homeworkSyncResult + for _, source := range sources { + result, err := syncHomeworkForSource(cmd, apiClient, source, lifeSemesterRaw, semesterID, dryRun) + if err != nil { + return err + } + results = append(results, result) + } + result := mergeHomeworkSyncResults(results, dryRun) + + if output.IsJSON() { + return output.JSON(result) + } + + output.KVWithTitle([]output.KVPair{ + {Key: "Programs", Value: strings.Join(homeworkSyncPrograms(results), ", ")}, + {Key: "School Homework", Value: len(result.SchoolHomework)}, + {Key: "Created", Value: len(result.Created)}, + {Key: "Matched", Value: len(result.Matched)}, + {Key: "Unmatched", Value: len(result.Unmatched)}, + {Key: "Dry Run", Value: dryRun}, + }, "Homework Sync") + + rows := homeworkSyncRows(result) + return output.OutputList(result, rows, []output.Column{ + {Key: "action", Header: "Action"}, + {Key: "course", Header: "Course"}, + {Key: "title", Header: "Title"}, + {Key: "due", Header: "Due"}, + {Key: "section", Header: "Section"}, + {Key: "completion", Header: "Done"}, + }, len(rows), 0) + }, + } + + addAuthFlags(cmd, &auth) + cmd.Flags().BoolVar(&allPrograms, "all-programs", false, "Sync undergraduate and graduate homework in one run") + cmd.Flags().IntVar(&semesterID, "semester-id", 0, "Catalog semester ID (defaults to current semester)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview without creating homework or updating completion") + return cmd +} + +func newCmdSchoolSync() *cobra.Command { + var auth authFlags + var allPrograms bool + var dryRun bool + + cmd := &cobra.Command{ + Use: "sync", + Short: "One-way sync all school lessons to Life@USTC", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + defer debugStep("school section sync")() + sources, err := newSchoolSyncSources(auth, allPrograms) + if err != nil { + return err + } + var curricula []schoolCurriculumResult + var skipped []map[string]any + for _, source := range sources { + sourceCurricula, sourceSkipped, err := fetchAllCurricula(cmd, source) + if err != nil { + return err + } + curricula = append(curricula, sourceCurricula...) + skipped = append(skipped, sourceSkipped...) + } + if len(curricula) == 0 { + return fmt.Errorf("no school semesters with lesson codes were found") + } + + apiClient, err := api.NewTypedClient(cmdutil.ServerFromCmd(cmd), true) + if err != nil { + return err + } + + lifeSemesterRaw, err := api.ParseResponseRaw(apiClient.ListSemesters(cmd.Context(), &openapi.ListSemestersParams{ + Limit: stringPtr("200"), + })) + if err != nil { + return err + } + + allSectionIDs := map[int]struct{}{} + var allCurriculum []ustcschool.CurriculumItem + var allCodes []string + var matchedCodes []any + var unmatchedCodes []any + var sections []any + var semesterResults []map[string]any + for _, curriculum := range curricula { + codes := uniqueLessonCodes(curriculum.Items) + if len(codes) == 0 { + continue + } + lifeSemesterID, lifeSemester, ok := resolveLifeSemester(lifeSemesterRaw, curriculum.Semester) + if !ok { + skipped = append(skipped, map[string]any{ + "program": curriculum.Program, + "semester": curriculum.Semester, + "reason": "could not map to a Life@USTC semester", + }) + continue + } + + matchRaw, err := api.ParseResponseRaw(apiClient.MatchSectionCodes(cmd.Context(), openapi.MatchSectionCodesJSONRequestBody{ + Codes: codes, + SemesterId: &lifeSemesterID, + })) + if err != nil { + return err + } + matchMap := cmdutil.AsMap(matchRaw) + for _, sectionID := range extractSectionIDs(matchMap["sections"]) { + allSectionIDs[sectionID] = struct{}{} + } + allCurriculum = append(allCurriculum, curriculum.Items...) + allCodes = append(allCodes, codes...) + matchedCodes = append(matchedCodes, anySlice(matchMap["matchedCodes"])...) + unmatchedCodes = append(unmatchedCodes, anySlice(matchMap["unmatchedCodes"])...) + sections = append(sections, anySlice(matchMap["sections"])...) + semesterResults = append(semesterResults, map[string]any{ + "program": curriculum.Program, + "semester": curriculum.Semester, + "lifeSemester": lifeSemester, + "codes": codes, + "matchedCodes": matchMap["matchedCodes"], + "unmatchedCodes": matchMap["unmatchedCodes"], + "sections": matchMap["sections"], + }) + } + + sectionIDs := sortedIntsFromSet(allSectionIDs) + result := map[string]any{ + "curricula": curricula, + "curriculum": allCurriculum, + "codes": allCodes, + "matchedCodes": matchedCodes, + "unmatchedCodes": unmatchedCodes, + "sections": sections, + "sectionIds": sectionIDs, + "semesterResults": semesterResults, + "skipped": skipped, + "dryRun": dryRun, + } + + if !dryRun && len(sectionIDs) > 0 { + subscribeRaw, err := api.ParseResponseRaw(apiClient.SetCalendarSubscription(cmd.Context(), openapi.SetCalendarSubscriptionJSONRequestBody{ + SectionIds: §ionIDs, + })) + if err != nil { + return err + } + result["subscription"] = subscribeRaw + } + + if output.IsJSON() { + return output.JSON(result) + } + + output.KVWithTitle([]output.KVPair{ + {Key: "School Semesters", Value: len(curricula)}, + {Key: "Skipped Semesters", Value: len(skipped)}, + {Key: "Lesson Code Count", Value: len(allCodes)}, + {Key: "Matched Sections", Value: len(sectionIDs)}, + {Key: "All Programs", Value: allPrograms}, + {Key: "Dry Run", Value: dryRun}, + }, "Sync") + + if len(unmatchedCodes) > 0 { + output.KVWithTitle([]output.KVPair{{Key: "Codes", Value: unmatchedCodes, SkipEmpty: true}}, "Unmatched Codes") + } + + rows := cmdutil.RowsFromAny(sections) + return output.OutputList( + sections, + rows, + []output.Column{ + {Key: "id", Header: "ID"}, + {Key: "code", Header: "Code"}, + {Key: "course.nameCn", Header: "Course"}, + {Key: "title", Header: "Title"}, + {Key: "semester.nameCn", Header: "Semester"}, + }, + 0, + 0, + ) + }, + } + + addAuthFlags(cmd, &auth) + cmd.Flags().BoolVar(&allPrograms, "all-programs", false, "Sync undergraduate and graduate lessons in one subscription update") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Crawl and match without updating Life@USTC calendar subscriptions") + return cmd +} + +func addAuthFlags(cmd *cobra.Command, auth *authFlags) { + cmd.Flags().StringVar(&auth.username, "username", "", "USTC passport username (env: PASSPORT_UNDERGRADUATE_USERNAME or PASSPORT_GRADUATE_USERNAME)") + cmd.Flags().StringVar(&auth.password, "password", "", "USTC passport password (env: PASSPORT_PASSWORD)") + cmd.Flags().StringVar(&auth.totp, "totp", "", "USTC OTP code or TOTP secret/otpauth URL (env: PASSPORT_TOTP)") + cmd.Flags().BoolVar(&auth.undergraduate, "undergraduate", false, "Use undergraduate-system credentials and endpoints") + cmd.Flags().BoolVar(&auth.graduate, "graduate", false, "Use graduate-system credentials and endpoints") +} + +func debugLog(format string, args ...any) { + if !schoolDebug { + return + } + fmt.Fprintf(os.Stderr, "[school debug] "+format+"\n", args...) +} + +func debugStep(name string) func() { + if !schoolDebug { + return func() {} + } + start := time.Now() + debugLog("start %s", name) + return func() { + debugLog("done %s (%s)", name, time.Since(start).Round(time.Millisecond)) + } +} + +func withDebugStep(name string, fn func() error) error { + done := debugStep(name) + defer done() + return fn() +} + +func configureSchoolDebug() { + if !schoolDebug { + ustcschool.SetDebugLogger(nil) + return + } + ustcschool.SetDebugLogger(debugLog) +} + +func newSchoolClient(auth authFlags) (*ustcschool.Client, error) { + configureSchoolDebug() + programs, err := selectSchoolPrograms(auth, false) + if err != nil { + return nil, err + } + if len(programs) != 1 { + return nil, fmt.Errorf("this command reads one school program at a time; use --undergraduate or --graduate") + } + program := programs[0] + + creds, err := ustcschool.ResolveCredentialsForProgram(program, auth.username, auth.password, auth.totp) + if err != nil { + return nil, err + } + return ustcschool.NewClient(creds, program), nil +} + +func newSchoolSyncSources(auth authFlags, allPrograms bool) ([]schoolSyncSource, error) { + configureSchoolDebug() + programs, err := selectSchoolPrograms(auth, allPrograms) + if err != nil { + return nil, err + } + + sources := make([]schoolSyncSource, 0, len(programs)) + for _, program := range programs { + creds, err := ustcschool.ResolveCredentialsForProgram(program, auth.username, auth.password, auth.totp) + if err != nil { + return nil, err + } + sources = append(sources, schoolSyncSource{ + Program: program, + Client: ustcschool.NewClient(creds, program), + }) + } + return sources, nil +} + +func selectSchoolPrograms(auth authFlags, allPrograms bool) ([]ustcschool.Program, error) { + if allPrograms { + return []ustcschool.Program{ustcschool.ProgramUndergraduate, ustcschool.ProgramGraduate}, nil + } + if auth.undergraduate && auth.graduate { + return []ustcschool.Program{ustcschool.ProgramUndergraduate, ustcschool.ProgramGraduate}, nil + } + if auth.undergraduate { + return []ustcschool.Program{ustcschool.ProgramUndergraduate}, nil + } + if auth.graduate { + return []ustcschool.Program{ustcschool.ProgramGraduate}, nil + } + if programs, err := configuredSchoolPrograms(); err != nil { + return nil, err + } else if len(programs) > 0 { + return programs, nil + } + if programs := ustcschool.DetectCredentialPrograms(); len(programs) > 0 { + return programs, nil + } + return []ustcschool.Program{ustcschool.ProgramUndergraduate}, nil +} + +func configuredSchoolPrograms() ([]ustcschool.Program, error) { + values := config.GetSchoolPrograms() + programs := make([]ustcschool.Program, 0, len(values)) + seen := map[ustcschool.Program]struct{}{} + for _, value := range values { + var program ustcschool.Program + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "none": + continue + case "undergrad", "undergraduate": + program = ustcschool.ProgramUndergraduate + case "grad", "graduate": + program = ustcschool.ProgramGraduate + default: + return nil, fmt.Errorf("invalid configured school program %q; run `life-ustc config set-school-programs undergraduate,graduate`", value) + } + if _, ok := seen[program]; ok { + continue + } + seen[program] = struct{}{} + programs = append(programs, program) + } + return programs, nil +} + +func uniqueLessonCodes(items []ustcschool.CurriculumItem) []string { + set := make(map[string]struct{}, len(items)) + var codes []string + for _, item := range items { + code := strings.TrimSpace(item.LessonCode) + if code == "" { + continue + } + if _, ok := set[code]; ok { + continue + } + set[code] = struct{}{} + codes = append(codes, code) + } + return codes +} + +func fetchAllCurricula(cmd *cobra.Command, source schoolSyncSource) ([]schoolCurriculumResult, []map[string]any, error) { + var semesters []ustcschool.Semester + if err := withDebugStep(fmt.Sprintf("%s fetch semesters", source.Program), func() error { + var err error + semesters, err = source.Client.FetchSemesters(cmd.Context()) + return err + }); err != nil { + return nil, nil, err + } + debugLog("%s semesters=%d", source.Program, len(semesters)) + + var curricula []schoolCurriculumResult + var skipped []map[string]any + seen := map[int]struct{}{} + for _, semester := range semesters { + if _, ok := seen[semester.ID]; ok { + continue + } + seen[semester.ID] = struct{}{} + + var selected ustcschool.Semester + var items []ustcschool.CurriculumItem + stepName := fmt.Sprintf("%s fetch curriculum semester=%d", source.Program, semester.ID) + if err := withDebugStep(stepName, func() error { + var err error + selected, items, err = source.Client.FetchCurriculum(cmd.Context(), semester.ID) + return err + }); err != nil { + if ustcschool.IsNoLessonData(err) { + skipped = append(skipped, map[string]any{ + "program": source.Program, + "semester": semester, + "reason": err.Error(), + }) + continue + } + return nil, nil, err + } + debugLog("%s semester=%d lessonCodes=%d items=%d", source.Program, selected.ID, len(uniqueLessonCodes(items)), len(items)) + if len(uniqueLessonCodes(items)) == 0 { + skipped = append(skipped, map[string]any{ + "program": source.Program, + "semester": selected, + "reason": "no lesson codes", + }) + continue + } + curricula = append(curricula, schoolCurriculumResult{ + Program: source.Program, + Semester: selected, + Items: items, + }) + } + return curricula, skipped, nil +} + +func anySlice(value any) []any { + items, ok := value.([]any) + if !ok { + return nil + } + return items +} + +func uniqueStrings(values []string) []string { + set := make(map[string]struct{}, len(values)) + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := set[value]; ok { + continue + } + set[value] = struct{}{} + out = append(out, value) + } + return out +} + +func sortedIntsFromSet(set map[int]struct{}) []int { + ids := make([]int, 0, len(set)) + for id := range set { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +type homeworkSyncResult struct { + Program ustcschool.Program `json:"program,omitempty"` + Semester ustcschool.Semester `json:"semester"` + LifeSemester map[string]any `json:"lifeSemester,omitempty"` + SchoolHomework []ustcschool.HomeworkItem `json:"schoolHomework"` + Created []homeworkSyncItemResult `json:"created"` + Matched []homeworkSyncItemResult `json:"matched"` + CompletionUpdated []homeworkSyncItemResult `json:"completionUpdated"` + Skipped []homeworkSyncItemResult `json:"skipped"` + Unmatched []homeworkSyncItemResult `json:"unmatched"` + Results []homeworkSyncResult `json:"results,omitempty"` + DryRun bool `json:"dryRun"` +} + +type homeworkSyncItemResult struct { + SchoolHomework ustcschool.HomeworkItem `json:"schoolHomework"` + LifeHomework map[string]any `json:"lifeHomework,omitempty"` + Section map[string]any `json:"section,omitempty"` + Action string `json:"action"` + Reason string `json:"reason,omitempty"` + Completion bool `json:"completion"` + CompletionKnown bool `json:"completionKnown"` + CompletionAction string `json:"completionAction,omitempty"` +} + +func newHomeworkSyncResult(program ustcschool.Program, semester ustcschool.Semester, lifeSemester map[string]any, homework []ustcschool.HomeworkItem, dryRun bool) homeworkSyncResult { + return homeworkSyncResult{ + Program: program, + Semester: semester, + LifeSemester: lifeSemester, + SchoolHomework: homework, + Created: []homeworkSyncItemResult{}, + Matched: []homeworkSyncItemResult{}, + Unmatched: []homeworkSyncItemResult{}, + Skipped: []homeworkSyncItemResult{}, + DryRun: dryRun, + } +} + +func syncHomeworkForSource(cmd *cobra.Command, apiClient *api.TypedClient, source schoolSyncSource, lifeSemesterRaw any, semesterID int, dryRun bool) (homeworkSyncResult, error) { + defer debugStep(fmt.Sprintf("%s homework sync source", source.Program))() + var homework []ustcschool.HomeworkItem + if err := withDebugStep(fmt.Sprintf("%s fetch school homework", source.Program), func() error { + var err error + homework, err = source.Client.FetchHomework(cmd.Context()) + return err + }); err != nil { + return homeworkSyncResult{}, err + } + debugLog("%s homework items=%d", source.Program, len(homework)) + + var sectionsByHomeworkCode map[string]map[string]any + if err := withDebugStep(fmt.Sprintf("%s map sections by homework code", source.Program), func() error { + var err error + sectionsByHomeworkCode, err = homeworkSectionsByCode(cmd, apiClient, lifeSemesterRaw, homework) + return err + }); err != nil { + return homeworkSyncResult{}, err + } + debugLog("%s homework-code section mappings=%d", source.Program, len(sectionsByHomeworkCode)) + + needsCourseMapping := false + for _, item := range homework { + if _, ok := sectionsByHomeworkCode[homeworkSectionKey(item)]; !ok { + needsCourseMapping = true + break + } + } + + var semester ustcschool.Semester + var lifeSemester map[string]any + sectionsByCourse := map[string]map[string]any{} + if needsCourseMapping { + if err := withDebugStep(fmt.Sprintf("%s map sections by course", source.Program), func() error { + var err error + semester, lifeSemester, sectionsByCourse, err = homeworkSectionsByCourse(cmd, apiClient, source, lifeSemesterRaw, semesterID) + return err + }); err != nil { + return homeworkSyncResult{}, err + } + debugLog("%s course section mappings=%d", source.Program, len(sectionsByCourse)) + } else { + debugLog("%s skipped course section mapping; homework codes covered all items", source.Program) + } + + result := newHomeworkSyncResult(source.Program, semester, lifeSemester, homework, dryRun) + existingBySection := map[string][]map[string]any{} + for _, item := range homework { + section, ok := sectionsByHomeworkCode[homeworkSectionKey(item)] + if !ok { + section, ok = sectionsByCourse[normalizeHomeworkKey(item.CourseName)] + } + if !ok { + result.Unmatched = append(result.Unmatched, homeworkSyncItemResult{ + SchoolHomework: item, + Action: "unmatched", + Reason: "no matched Life@USTC section for course", + }) + continue + } + + sectionID, ok := anyIntString(section["id"]) + if !ok || sectionID == "" { + result.Unmatched = append(result.Unmatched, homeworkSyncItemResult{ + SchoolHomework: item, + Section: section, + Action: "unmatched", + Reason: "matched section has no id", + }) + continue + } + + existing, ok := existingBySection[sectionID] + if !ok { + if err := withDebugStep(fmt.Sprintf("Life@USTC list homework section=%s", sectionID), func() error { + var err error + existing, err = fetchLifeHomeworksForSection(cmd, apiClient, sectionID) + return err + }); err != nil { + return homeworkSyncResult{}, err + } + existingBySection[sectionID] = existing + } + + matched := matchLifeHomework(existing, item) + homeworkID := anyString(matched["id"]) + action := "matched" + if homeworkID == "" { + action = "created" + if !dryRun { + created, err := createLifeHomework(cmd, apiClient, sectionID, item) + if err != nil { + return homeworkSyncResult{}, err + } + matched = created + homeworkID = anyString(created["id"]) + if homeworkID == "" { + return homeworkSyncResult{}, fmt.Errorf("created homework for %q did not return an id", item.Title) + } + existingBySection[sectionID] = append(existingBySection[sectionID], created) + } + } + + completed, completionKnown := schoolHomeworkCompletion(item) + completionAction := "unknown" + if completionKnown { + completionAction = "planned" + } + if completionKnown && !dryRun && homeworkID != "" { + if err := setLifeHomeworkCompletion(cmd, apiClient, homeworkID, completed); err != nil { + return homeworkSyncResult{}, err + } + completionAction = "updated" + } + + row := homeworkSyncItemResult{ + SchoolHomework: item, + LifeHomework: matched, + Section: section, + Action: action, + Completion: completed, + CompletionKnown: completionKnown, + CompletionAction: completionAction, + } + if action == "created" { + result.Created = append(result.Created, row) + } else { + result.Matched = append(result.Matched, row) + } + if completionKnown { + result.CompletionUpdated = append(result.CompletionUpdated, row) + } + } + return result, nil +} + +func homeworkSectionsByCode(cmd *cobra.Command, apiClient *api.TypedClient, lifeSemesterRaw any, homework []ustcschool.HomeworkItem) (map[string]map[string]any, error) { + codesByLifeSemester := map[string][]string{} + blackboardSemesterByLifeCode := map[string]string{} + for _, item := range homework { + code := strings.TrimSpace(item.LessonCode) + semesterCode := strings.TrimSpace(item.SemesterCode) + if code == "" || semesterCode == "" { + continue + } + lifeSemesterID, ok := resolveBlackboardLifeSemester(lifeSemesterRaw, semesterCode) + if !ok { + continue + } + codesByLifeSemester[lifeSemesterID] = append(codesByLifeSemester[lifeSemesterID], code) + blackboardSemesterByLifeCode[lifeSemesterID+"|"+code] = semesterCode + } + + out := map[string]map[string]any{} + for lifeSemesterID, codes := range codesByLifeSemester { + codes = uniqueStrings(codes) + matchRaw, err := api.ParseResponseRaw(apiClient.MatchSectionCodes(cmd.Context(), openapi.MatchSectionCodesJSONRequestBody{ + Codes: codes, + SemesterId: &lifeSemesterID, + })) + if err != nil { + return nil, err + } + for code, section := range sectionsByCode(cmdutil.AsMap(matchRaw)["sections"]) { + semesterCode := blackboardSemesterByLifeCode[lifeSemesterID+"|"+code] + out[code+"|"+semesterCode] = section + } + } + return out, nil +} + +func resolveBlackboardLifeSemester(raw any, blackboardSemesterCode string) (string, bool) { + year, term, ok := parseBlackboardSemesterCode(blackboardSemesterCode) + if !ok { + return "", false + } + result := cmdutil.NewListResult(raw, "data") + for _, row := range result.Rows { + id, ok := anyIntString(row["id"]) + if !ok { + continue + } + name := anyString(row["nameCn"]) + if strings.Contains(name, year+"年") && strings.Contains(name, term) { + return id, true + } + } + return "", false +} + +func parseBlackboardSemesterCode(code string) (string, string, bool) { + code = strings.TrimSpace(code) + if len(code) != 6 { + return "", "", false + } + year := code[:4] + for _, r := range year { + if r < '0' || r > '9' { + return "", "", false + } + } + switch code[4:] { + case "SP": + return year, "春季", true + case "SU": + return year, "夏季", true + case "FA": + return year, "秋季", true + default: + return "", "", false + } +} + +func homeworkSectionKey(item ustcschool.HomeworkItem) string { + code := strings.TrimSpace(item.LessonCode) + semester := strings.TrimSpace(item.SemesterCode) + if code == "" || semester == "" { + return "" + } + return code + "|" + semester +} + +func homeworkSectionsByCourse(cmd *cobra.Command, apiClient *api.TypedClient, source schoolSyncSource, lifeSemesterRaw any, semesterID int) (ustcschool.Semester, map[string]any, map[string]map[string]any, error) { + curricula, err := homeworkSyncCurricula(cmd, source, semesterID) + if err != nil { + return ustcschool.Semester{}, nil, nil, err + } + if len(curricula) == 0 { + return ustcschool.Semester{}, nil, nil, fmt.Errorf("no school lesson codes found for %s", source.Program) + } + + var firstSemester ustcschool.Semester + var firstLifeSemester map[string]any + sectionsByCourse := map[string]map[string]any{} + for index, curriculum := range curricula { + if index == 0 { + firstSemester = curriculum.Semester + } + codes := uniqueLessonCodes(curriculum.Items) + if len(codes) == 0 { + continue + } + lifeSemesterID, lifeSemester, ok := resolveLifeSemester(lifeSemesterRaw, curriculum.Semester) + if !ok { + continue + } + if firstLifeSemester == nil { + firstLifeSemester = lifeSemester + } + matchRaw, err := api.ParseResponseRaw(apiClient.MatchSectionCodes(cmd.Context(), openapi.MatchSectionCodesJSONRequestBody{ + Codes: codes, + SemesterId: &lifeSemesterID, + })) + if err != nil { + return ustcschool.Semester{}, nil, nil, err + } + matchMap := cmdutil.AsMap(matchRaw) + for course, section := range sectionsByCourseName(curriculum.Items, sectionsByCode(matchMap["sections"])) { + sectionsByCourse[course] = section + } + } + if len(sectionsByCourse) == 0 { + return ustcschool.Semester{}, nil, nil, fmt.Errorf("could not map any %s school semesters to Life@USTC sections", source.Program) + } + return firstSemester, firstLifeSemester, sectionsByCourse, nil +} + +func homeworkSyncCurricula(cmd *cobra.Command, source schoolSyncSource, semesterID int) ([]schoolCurriculumResult, error) { + if semesterID != 0 || source.Program.IsGraduate() { + semester, curriculum, err := source.Client.FetchCurriculum(cmd.Context(), semesterID) + if err != nil { + return nil, err + } + if len(uniqueLessonCodes(curriculum)) == 0 { + return nil, nil + } + return []schoolCurriculumResult{{ + Program: source.Program, + Semester: semester, + Items: curriculum, + }}, nil + } + + curricula, _, err := fetchAllCurricula(cmd, source) + return curricula, err +} + +func mergeHomeworkSyncResults(results []homeworkSyncResult, dryRun bool) homeworkSyncResult { + merged := homeworkSyncResult{ + Program: "all", + SchoolHomework: []ustcschool.HomeworkItem{}, + Created: []homeworkSyncItemResult{}, + Matched: []homeworkSyncItemResult{}, + CompletionUpdated: []homeworkSyncItemResult{}, + Skipped: []homeworkSyncItemResult{}, + Unmatched: []homeworkSyncItemResult{}, + Results: results, + DryRun: dryRun, + } + if len(results) == 1 { + results[0].Results = nil + return results[0] + } + for _, result := range results { + merged.SchoolHomework = append(merged.SchoolHomework, result.SchoolHomework...) + merged.Created = append(merged.Created, result.Created...) + merged.Matched = append(merged.Matched, result.Matched...) + merged.CompletionUpdated = append(merged.CompletionUpdated, result.CompletionUpdated...) + merged.Skipped = append(merged.Skipped, result.Skipped...) + merged.Unmatched = append(merged.Unmatched, result.Unmatched...) + } + return merged +} + +func homeworkSyncPrograms(results []homeworkSyncResult) []string { + programs := make([]string, 0, len(results)) + for _, result := range results { + if result.Program != "" { + programs = append(programs, string(result.Program)) + } + } + return programs +} + +func sectionsByCode(raw any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, section := range cmdutil.RowsFromAny(raw) { + code := anyString(section["code"]) + if code != "" { + out[code] = section + } + } + return out +} + +func sectionsByCourseName(curriculum []ustcschool.CurriculumItem, byCode map[string]map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, item := range curriculum { + section := byCode[item.LessonCode] + if section == nil { + continue + } + courseName := normalizeHomeworkKey(item.CourseName) + if courseName != "" { + out[courseName] = section + } + } + return out +} + +func fetchLifeHomeworksForSection(cmd *cobra.Command, apiClient *api.TypedClient, sectionID string) ([]map[string]any, error) { + includeDeleted := openapi.ListHomeworksParamsIncludeDeletedFalse + raw, err := api.ParseResponseRaw(apiClient.ListHomeworks(cmd.Context(), &openapi.ListHomeworksParams{ + SectionId: §ionID, + IncludeDeleted: &includeDeleted, + })) + if err != nil { + return nil, err + } + return cmdutil.NewListResult(raw, "homeworks").Rows, nil +} + +func createLifeHomework(cmd *cobra.Command, apiClient *api.TypedClient, sectionID string, item ustcschool.HomeworkItem) (map[string]any, error) { + body := openapi.CreateHomeworkJSONRequestBody{ + SectionId: sectionID, + Title: item.Title, + } + if start := lifeHomeworkTime(item.StartAt); start != "" { + body.SubmissionStartAt = &start + } + if due := lifeHomeworkTime(item.EndAt); due != "" { + body.SubmissionDueAt = &due + } + raw, err := api.ParseResponseRaw(apiClient.CreateHomework(cmd.Context(), nil, body)) + if err != nil { + return nil, err + } + return unwrapHomeworkMap(raw), nil +} + +func setLifeHomeworkCompletion(cmd *cobra.Command, apiClient *api.TypedClient, homeworkID string, completed bool) error { + _, err := api.ParseResponseRaw(apiClient.SetHomeworkCompletion(cmd.Context(), homeworkID, openapi.SetHomeworkCompletionJSONRequestBody{ + Completed: completed, + })) + return err +} + +func matchLifeHomework(existing []map[string]any, item ustcschool.HomeworkItem) map[string]any { + title := normalizeHomeworkKey(item.Title) + due := normalizedHomeworkTimeKey(item.EndAt) + for _, row := range existing { + if normalizeHomeworkKey(anyString(row["title"])) != title { + continue + } + if normalizedHomeworkTimeKey(anyString(row["submissionDueAt"])) == due { + return row + } + } + return nil +} + +func unwrapHomeworkMap(raw any) map[string]any { + row := cmdutil.AsMap(raw) + if row == nil { + return nil + } + if homework := cmdutil.AsMap(row["homework"]); homework != nil { + return homework + } + return row +} + +func schoolHomeworkCompleted(item ustcschool.HomeworkItem) bool { + completed, _ := schoolHomeworkCompletion(item) + return completed +} + +func schoolHomeworkCompletion(item ustcschool.HomeworkItem) (bool, bool) { + switch normalizeHomeworkKey(item.Status) { + case "submitted", "graded": + return true, true + case "已提交", "已评分", "需要评分": + return true, true + case "pending", "overdue": + return false, true + case "尚未提交", "尚未评分": + return false, true + default: + return false, false + } +} + +func normalizeHomeworkKey(value string) string { + return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " ")) +} + +func lifeHomeworkTime(value string) string { + parsed, ok := parseHomeworkTime(value) + if !ok { + return "" + } + return parsed.Format(time.RFC3339) +} + +func normalizedHomeworkTimeKey(value string) string { + parsed, ok := parseHomeworkTime(value) + if !ok { + return "" + } + return parsed.UTC().Format(time.RFC3339) +} + +func parseHomeworkTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, true + } + } + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02"} { + if parsed, err := time.ParseInLocation(layout, value, schoolTimeLocation); err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func homeworkSyncRows(result homeworkSyncResult) []map[string]any { + var rows []map[string]any + for _, group := range [][]homeworkSyncItemResult{result.Created, result.Matched, result.Unmatched, result.Skipped} { + for _, item := range group { + rows = append(rows, map[string]any{ + "action": item.Action, + "course": item.SchoolHomework.CourseName, + "title": item.SchoolHomework.Title, + "due": item.SchoolHomework.EndAt, + "section": anyString(item.Section["code"]), + "completion": item.Completion, + "reason": item.Reason, + }) + } + } + return rows +} + +func extractSectionIDs(raw any) []int { + sections, ok := raw.([]any) + if !ok { + return nil + } + + ids := make([]int, 0, len(sections)) + for _, section := range sections { + row, ok := section.(map[string]any) + if !ok { + continue + } + switch value := row["id"].(type) { + case float64: + ids = append(ids, int(value)) + case string: + id, err := strconv.Atoi(value) + if err == nil { + ids = append(ids, id) + } + } + } + return ids +} + +func resolveLifeSemester(raw any, schoolSemester ustcschool.Semester) (string, map[string]any, bool) { + result := cmdutil.NewListResult(raw, "data") + if len(result.Rows) == 0 { + return "", nil, false + } + + schoolCode := strings.TrimSpace(schoolSemester.Code) + schoolID := strconv.Itoa(schoolSemester.ID) + schoolName := strings.TrimSpace(schoolSemester.Name()) + + var fallbackID string + var fallbackRow map[string]any + for _, row := range result.Rows { + id, ok := anyIntString(row["id"]) + if !ok { + continue + } + if jwID, ok := anyInt(row["jwId"]); ok && jwID == schoolSemester.ID { + return id, row, true + } + + code := anyString(row["code"]) + name := anyString(row["nameCn"]) + if code == "" && name == "" { + continue + } + if code == schoolID { + return id, row, true + } + if fallbackID == "" && ((schoolCode != "" && code == schoolCode) || (schoolName != "" && name == schoolName)) { + fallbackID = id + fallbackRow = row + } + } + if fallbackID != "" { + return fallbackID, fallbackRow, true + } + return "", nil, false +} + +func anyInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case int32: + return int(typed), true + case int64: + return int(typed), true + case float64: + return int(typed), true + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err == nil { + return parsed, true + } + } + return 0, false +} + +func anyIntString(value any) (string, bool) { + parsed, ok := anyInt(value) + if !ok { + return "", false + } + return strconv.Itoa(parsed), true +} + +func anyString(value any) string { + if value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +func stringPtr(value string) *string { + return &value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/cmd/school/school_test.go b/internal/cmd/school/school_test.go new file mode 100644 index 0000000..bc0407c --- /dev/null +++ b/internal/cmd/school/school_test.go @@ -0,0 +1,167 @@ +package school + +import ( + "testing" + + ustcschool "github.com/Life-USTC/CLI/internal/school" +) + +func TestResolveLifeSemesterMatchesJWID(t *testing.T) { + t.Parallel() + + id, row, ok := resolveLifeSemester(map[string]any{ + "data": []any{ + map[string]any{ + "id": float64(77), + "jwId": float64(362), + "code": "362", + "nameCn": "2024年秋季学期", + }, + }, + }, ustcschool.Semester{ID: 362, Code: "20241", SemesterCn: "2024年秋季学期"}) + if !ok { + t.Fatal("resolveLifeSemester() did not find a matching semester") + } + if id != "77" { + t.Fatalf("resolveLifeSemester() id = %q, want %q", id, "77") + } + if got := row["nameCn"]; got != "2024年秋季学期" { + t.Fatalf("resolveLifeSemester() nameCn = %v, want %q", got, "2024年秋季学期") + } +} + +func TestResolveLifeSemesterFallsBackToSemesterIDCode(t *testing.T) { + t.Parallel() + + id, _, ok := resolveLifeSemester(map[string]any{ + "data": []any{ + map[string]any{ + "id": "77", + "code": "362", + "nameCn": "2024年秋季学期", + }, + }, + }, ustcschool.Semester{ID: 362, Code: "20241"}) + if !ok { + t.Fatal("resolveLifeSemester() did not match by semester ID fallback") + } + if id != "77" { + t.Fatalf("resolveLifeSemester() id = %q, want %q", id, "77") + } +} + +func TestResolveLifeSemesterReturnsFalseWithoutMatch(t *testing.T) { + t.Parallel() + + if _, _, ok := resolveLifeSemester(map[string]any{ + "data": []any{ + map[string]any{ + "id": float64(71), + "jwId": float64(421), + "code": "421", + "nameCn": "2026年春季学期", + }, + }, + }, ustcschool.Semester{ID: 362, Code: "20241"}); ok { + t.Fatal("resolveLifeSemester() unexpectedly found a match") + } +} + +func TestSchoolHomeworkCompleted(t *testing.T) { + t.Parallel() + + tests := []struct { + status string + want bool + }{ + {status: "submitted", want: true}, + {status: "graded", want: true}, + {status: "pending", want: false}, + {status: "overdue", want: false}, + } + for _, tc := range tests { + got := schoolHomeworkCompleted(ustcschool.HomeworkItem{Status: tc.status}) + if got != tc.want { + t.Fatalf("schoolHomeworkCompleted(%q) = %v, want %v", tc.status, got, tc.want) + } + } +} + +func TestSchoolHomeworkCompletionKnown(t *testing.T) { + t.Parallel() + + tests := []struct { + status string + wantComplete bool + wantKnown bool + }{ + {status: "submitted", wantComplete: true, wantKnown: true}, + {status: "graded", wantComplete: true, wantKnown: true}, + {status: "pending", wantComplete: false, wantKnown: true}, + {status: "overdue", wantComplete: false, wantKnown: true}, + {status: "已评分", wantComplete: true, wantKnown: true}, + {status: "已提交", wantComplete: true, wantKnown: true}, + {status: "需要评分", wantComplete: true, wantKnown: true}, + {status: "尚未评分", wantComplete: false, wantKnown: true}, + {status: "作业", wantComplete: false, wantKnown: false}, + } + for _, tc := range tests { + gotComplete, gotKnown := schoolHomeworkCompletion(ustcschool.HomeworkItem{Status: tc.status}) + if gotComplete != tc.wantComplete || gotKnown != tc.wantKnown { + t.Fatalf("schoolHomeworkCompletion(%q) = (%v, %v), want (%v, %v)", tc.status, gotComplete, gotKnown, tc.wantComplete, tc.wantKnown) + } + } +} + +func TestParseBlackboardSemesterCode(t *testing.T) { + t.Parallel() + + year, term, ok := parseBlackboardSemesterCode("2023FA") + if !ok { + t.Fatal("parseBlackboardSemesterCode() did not parse valid code") + } + if year != "2023" || term != "秋季" { + t.Fatalf("parseBlackboardSemesterCode() = (%q, %q), want (%q, %q)", year, term, "2023", "秋季") + } +} + +func TestMatchLifeHomeworkByTitleAndDueTime(t *testing.T) { + t.Parallel() + + existing := []map[string]any{ + { + "id": "hw-1", + "title": " 组合数学第四次作业 ", + "submissionDueAt": "2026-06-02T23:59:00+08:00", + }, + } + got := matchLifeHomework(existing, ustcschool.HomeworkItem{ + Title: "组合数学第四次作业", + EndAt: "2026-06-02 23:59:00", + }) + if got == nil { + t.Fatal("matchLifeHomework() did not find expected homework") + } + if got["id"] != "hw-1" { + t.Fatalf("matchLifeHomework() id = %v, want hw-1", got["id"]) + } +} + +func TestMatchLifeHomeworkRequiresSameDueTime(t *testing.T) { + t.Parallel() + + existing := []map[string]any{ + { + "id": "hw-1", + "title": "组合数学第四次作业", + "submissionDueAt": "2026-06-03T23:59:00+08:00", + }, + } + got := matchLifeHomework(existing, ustcschool.HomeworkItem{ + Title: "组合数学第四次作业", + EndAt: "2026-06-02 23:59:00", + }) + if got != nil { + t.Fatalf("matchLifeHomework() = %v, want nil", got) + } +} From 68e7aea02361b7313061d9c446aa967fbcb832c4 Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Mon, 1 Jun 2026 12:01:26 +0800 Subject: [PATCH 4/6] Fix school sync CI issues --- internal/school/browser.go | 14 +++++--------- internal/school/client.go | 26 +------------------------- internal/school/credentials_test.go | 2 +- internal/school/debug.go | 9 +-------- 4 files changed, 8 insertions(+), 43 deletions(-) diff --git a/internal/school/browser.go b/internal/school/browser.go index fca673e..9c9a096 100644 --- a/internal/school/browser.go +++ b/internal/school/browser.go @@ -104,10 +104,6 @@ func newAuthenticatedClientForTargets(ctx context.Context, creds Credentials, ta return nil, lastErr } -func openAuthenticatedClient(ctx context.Context, creds Credentials, target loginTarget) (*http.Client, error) { - return openAuthenticatedClientForTargets(ctx, creds, []loginTarget{target}) -} - func openAuthenticatedClientForTargets(ctx context.Context, creds Credentials, targets []loginTarget) (*http.Client, error) { client, err := newSchoolHTTPClient() if err != nil { @@ -170,7 +166,7 @@ func fetchCASLoginPage(ctx context.Context, client *http.Client, loginURL string if err != nil { return casLoginPage{}, fmt.Errorf("open login page: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return casLoginPage{}, responseError(res) } @@ -239,7 +235,7 @@ func submitCASLogin(ctx context.Context, client *http.Client, page casLoginPage, if err != nil { return nil, nil, fmt.Errorf("submit login form: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(res.Body) if err != nil { @@ -306,14 +302,14 @@ func primeAuthenticatedSession(ctx context.Context, client *http.Client, urls [] } if res.StatusCode >= 300 { err = responseError(res) - res.Body.Close() + _ = res.Body.Close() return err } if _, readErr := io.Copy(io.Discard, res.Body); readErr != nil { - res.Body.Close() + _ = res.Body.Close() return fmt.Errorf("read %s: %w", rawURL, readErr) } - res.Body.Close() + _ = res.Body.Close() } return nil } diff --git a/internal/school/client.go b/internal/school/client.go index d5d8447..029ca1e 100644 --- a/internal/school/client.go +++ b/internal/school/client.go @@ -813,7 +813,7 @@ func flattenScoreItems(items []scoreAPIItem, defaultSemesterID int, defaultSemes } func fetchJSON[T any](ctx context.Context, client *http.Client, rawURL string, query url.Values, out *T) error { - if query != nil && len(query) > 0 { + if len(query) > 0 { rawURL += "?" + query.Encode() } @@ -838,30 +838,6 @@ func fetchJSON[T any](ctx context.Context, client *http.Client, rawURL string, q return nil } -func postFormJSON[T any](ctx context.Context, client *http.Client, rawURL string, form url.Values, out *T) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Accept", "application/json, text/plain, */*") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") - req.Header.Set("User-Agent", schoolUserAgent) - req.Header.Set("X-Requested-With", "XMLHttpRequest") - - res, err := client.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - if res.StatusCode >= 300 { - return responseError(res) - } - if err := json.NewDecoder(res.Body).Decode(out); err != nil { - return fmt.Errorf("decode %s: %w", rawURL, err) - } - return nil -} - func joinInts(values []int) string { if len(values) == 0 { return "" diff --git a/internal/school/credentials_test.go b/internal/school/credentials_test.go index 5e1e9b6..e10a123 100644 --- a/internal/school/credentials_test.go +++ b/internal/school/credentials_test.go @@ -44,7 +44,7 @@ func TestResolveGraduateCredentialsPreferGraduateUsername(t *testing.T) { t.Setenv("PASSPORT_GRADUATE_USERNAME", "graduate-specific") t.Setenv("PASSPORT_UNDERGRADUATE_USERNAME", "undergrad-alias") t.Setenv("PASSPORT_PASSWORD", "shared-pass") - t.Setenv("PASSPORT_TOTP", "") + t.Setenv("PASSPORT_TOTP", "123456") creds, err := ResolveCredentialsForProgram(ProgramGraduate, "", "", "") if err != nil { diff --git a/internal/school/debug.go b/internal/school/debug.go index 9356902..d0d596f 100644 --- a/internal/school/debug.go +++ b/internal/school/debug.go @@ -1,9 +1,6 @@ package school -import ( - "fmt" - "time" -) +import "time" var debugf func(format string, args ...any) @@ -28,10 +25,6 @@ func debugStep(name string) func() { } } -func debugStepf(format string, args ...any) func() { - return debugStep(fmt.Sprintf(format, args...)) -} - func withSchoolDebugStep(name string, fn func() error) error { done := debugStep(name) defer done() From 81ce3024e85e8389d2405031792afc44d1f4c5a4 Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Mon, 1 Jun 2026 12:03:04 +0800 Subject: [PATCH 5/6] Fix remaining school client lint --- internal/school/client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/school/client.go b/internal/school/client.go index 029ca1e..01fed6d 100644 --- a/internal/school/client.go +++ b/internal/school/client.go @@ -75,7 +75,7 @@ func (c *Client) FetchSemesters(ctx context.Context) ([]Semester, error) { if err != nil { return nil, fmt.Errorf("fetch semesters: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { jwClient, err := newJWClient(ctx, c.creds) if err != nil { @@ -241,7 +241,7 @@ func (c *Client) FetchExams(ctx context.Context, semesterIDs ...int) ([]ExamItem if err != nil { return nil, fmt.Errorf("fetch exams: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return nil, responseError(res) } @@ -386,7 +386,7 @@ func fetchBlackboardCourseCalendarIDs(ctx context.Context, client *http.Client) if err != nil { return nil, fmt.Errorf("fetch Blackboard calendars: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return nil, responseError(res) } From 27a4a469a86e98f13951d8a5e4635889ce052f87 Mon Sep 17 00:00:00 2001 From: Tiankai Ma Date: Mon, 1 Jun 2026 12:04:15 +0800 Subject: [PATCH 6/6] Handle school response close errors --- internal/school/client.go | 16 ++++++++-------- internal/school/graduate.go | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/school/client.go b/internal/school/client.go index 01fed6d..e9006fc 100644 --- a/internal/school/client.go +++ b/internal/school/client.go @@ -416,7 +416,7 @@ func fetchBlackboardCalendarEvents(ctx context.Context, client *http.Client, cal if err != nil { return nil, fmt.Errorf("fetch Blackboard homework: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return nil, responseError(res) } @@ -438,7 +438,7 @@ func fetchBlackboardCourseIDs(ctx context.Context, client *http.Client) (map[str if err != nil { return nil, fmt.Errorf("fetch Blackboard portal courses: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return nil, responseError(res) } @@ -480,7 +480,7 @@ func fetchBlackboardGradeStatuses(ctx context.Context, client *http.Client, cour if err != nil { return nil, fmt.Errorf("fetch Blackboard grades: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return nil, responseError(res) } @@ -593,7 +593,7 @@ func fetchStudentID(ctx context.Context, client *http.Client) (string, error) { if err != nil { return "", fmt.Errorf("fetch course table page: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return "", responseError(res) } @@ -610,7 +610,7 @@ func fetchCurrentCourseTable(ctx context.Context, client *http.Client, semesterI if err != nil { return nil, nil, fmt.Errorf("fetch course table data: %w", err) } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return nil, nil, responseError(res) } @@ -664,12 +664,12 @@ func fetchCatalogLessons(ctx context.Context, client *http.Client, semesterID in } if res.StatusCode >= 300 { err := responseError(res) - res.Body.Close() + _ = res.Body.Close() return nil, err } body, err := io.ReadAll(res.Body) - res.Body.Close() + _ = res.Body.Close() if err != nil { return nil, fmt.Errorf("read catalog lessons: %w", err) } @@ -828,7 +828,7 @@ func fetchJSON[T any](ctx context.Context, client *http.Client, rawURL string, q if err != nil { return err } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode >= 300 { return responseError(res) } diff --git a/internal/school/graduate.go b/internal/school/graduate.go index 713840b..2172489 100644 --- a/internal/school/graduate.go +++ b/internal/school/graduate.go @@ -370,7 +370,7 @@ func postGraduateYJS1FormJSON(ctx context.Context, client *http.Client, appPageU if err != nil { return err } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { return responseError(res) @@ -390,7 +390,7 @@ func fetchGraduateYJS1UserID(ctx context.Context, client *http.Client, appPageUR if err != nil { return "", err } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { return "", responseError(res)