diff --git a/.opencode/skills/use-modern-go/SKILL.md b/.opencode/skills/use-modern-go/SKILL.md new file mode 100644 index 0000000..3755a1a --- /dev/null +++ b/.opencode/skills/use-modern-go/SKILL.md @@ -0,0 +1,291 @@ +--- +name: use-modern-go +description: Apply modern Go syntax guidelines based on project's Go version. Use when user ask for modern Go code guidelines. +--- + +# Modern Go Guidelines + +## Detected Go Version + +!`grep -rh "^go " --include="go.mod" . 2>/dev/null | cut -d' ' -f2 | sort | uniq -c | sort -nr | head -1 | xargs | cut -d' ' -f2 | grep . || echo unknown` + +## How to Use This Skill + +DO NOT search for go.mod files or try to detect the version yourself. Use ONLY the version shown above. + +**If version detected (not "unknown"):** +- Say: "This project is using Go X.XX, so I’ll stick to modern Go best practices and freely use language features up to and including this version. If you’d prefer a different target version, just let me know." +- Do NOT list features, do NOT ask for confirmation + +**If version is "unknown":** +- Say: "Could not detect Go version in this repository" +- Use AskUserQuestion: "Which Go version should I target?" → [1.23] / [1.24] / [1.25] / [1.26] + +**When writing Go code**, use ALL features from this document up to the target version: +- Prefer modern built-ins and packages (`slices`, `maps`, `cmp`) over legacy patterns +- Never use features from newer Go versions than the target +- Never use outdated patterns when a modern alternative is available + +--- + +## Features by Go Version + +### Go 1.0+ + +- `time.Since`: `time.Since(start)` instead of `time.Now().Sub(start)` + +### Go 1.8+ + +- `time.Until`: `time.Until(deadline)` instead of `deadline.Sub(time.Now())` + +### Go 1.13+ + +- `errors.Is`: `errors.Is(err, target)` instead of `err == target` (works with wrapped errors) + +### Go 1.18+ + +- `any`: Use `any` instead of `interface{}` +- `bytes.Cut`: `before, after, found := bytes.Cut(b, sep)` instead of Index+slice +- `strings.Cut`: `before, after, found := strings.Cut(s, sep)` + +### Go 1.19+ + +- `fmt.Appendf`: `buf = fmt.Appendf(buf, "x=%d", x)` instead of `[]byte(fmt.Sprintf(...))` +- `atomic.Bool`/`atomic.Int64`/`atomic.Pointer[T]`: Type-safe atomics instead of `atomic.StoreInt32` + +```go +var flag atomic.Bool +flag.Store(true) +if flag.Load() { ... } + +var ptr atomic.Pointer[Config] +ptr.Store(cfg) +``` + +### Go 1.20+ + +- `strings.Clone`: `strings.Clone(s)` to copy string without sharing memory +- `bytes.Clone`: `bytes.Clone(b)` to copy byte slice +- `strings.CutPrefix/CutSuffix`: `if rest, ok := strings.CutPrefix(s, "pre:"); ok { ... }` +- `errors.Join`: `errors.Join(err1, err2)` to combine multiple errors +- `context.WithCancelCause`: `ctx, cancel := context.WithCancelCause(parent)` then `cancel(err)` +- `context.Cause`: `context.Cause(ctx)` to get the error that caused cancellation + +### Go 1.21+ + +**Built-ins:** +- `min`/`max`: `max(a, b)` instead of if/else comparisons +- `clear`: `clear(m)` to delete all map entries, `clear(s)` to zero slice elements + +**slices package:** +- `slices.Contains`: `slices.Contains(items, x)` instead of manual loops +- `slices.Index`: `slices.Index(items, x)` returns index (-1 if not found) +- `slices.IndexFunc`: `slices.IndexFunc(items, func(item T) bool { return item.ID == id })` +- `slices.SortFunc`: `slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })` +- `slices.Sort`: `slices.Sort(items)` for ordered types +- `slices.Max`/`slices.Min`: `slices.Max(items)` instead of manual loop +- `slices.Reverse`: `slices.Reverse(items)` instead of manual swap loop +- `slices.Compact`: `slices.Compact(items)` removes consecutive duplicates in-place +- `slices.Clip`: `slices.Clip(s)` removes unused capacity +- `slices.Clone`: `slices.Clone(s)` creates a copy + +**maps package:** +- `maps.Clone`: `maps.Clone(m)` instead of manual map iteration +- `maps.Copy`: `maps.Copy(dst, src)` copies entries from src to dst +- `maps.DeleteFunc`: `maps.DeleteFunc(m, func(k K, v V) bool { return condition })` + +**sync package:** +- `sync.OnceFunc`: `f := sync.OnceFunc(func() { ... })` instead of `sync.Once` + wrapper +- `sync.OnceValue`: `getter := sync.OnceValue(func() T { return computeValue() })` + +**context package:** +- `context.AfterFunc`: `stop := context.AfterFunc(ctx, cleanup)` runs cleanup on cancellation +- `context.WithTimeoutCause`: `ctx, cancel := context.WithTimeoutCause(parent, d, err)` +- `context.WithDeadlineCause`: Similar with deadline instead of duration + +### Go 1.22+ + +**Loops:** +- `for i := range n`: `for i := range len(items)` instead of `for i := 0; i < len(items); i++` +- Loop variables are now safe to capture in goroutines (each iteration has its own copy) + +**cmp package:** +- `cmp.Or`: `cmp.Or(flag, env, config, "default")` returns first non-zero value + +```go +// Instead of: +name := os.Getenv("NAME") +if name == "" { + name = "default" +} +// Use: +name := cmp.Or(os.Getenv("NAME"), "default") +``` + +**reflect package:** +- `reflect.TypeFor`: `reflect.TypeFor[T]()` instead of `reflect.TypeOf((*T)(nil)).Elem()` + +**net/http:** +- Enhanced `http.ServeMux` patterns: `mux.HandleFunc("GET /api/{id}", handler)` with method and path params +- `r.PathValue("id")` to get path parameters + +### Go 1.23+ + +- `maps.Keys(m)` / `maps.Values(m)` return iterators +- `slices.Collect(iter)` not manual loop to build slice from iterator +- `slices.Sorted(iter)` to collect and sort in one step + +```go +keys := slices.Collect(maps.Keys(m)) // not: for k := range m { keys = append(keys, k) } +sortedKeys := slices.Sorted(maps.Keys(m)) // collect + sort +for k := range maps.Keys(m) { process(k) } // iterate directly +``` + +**time package** + +- `time.Tick`: Use `time.Tick` freely — as of Go 1.23, the garbage collector can recover unreferenced tickers, even if they haven't been stopped. The Stop method is no longer necessary to help the garbage collector. There is no longer any reason to prefer NewTicker when Tick will do. + +### Go 1.24+ + +- `t.Context()` not `context.WithCancel(context.Background())` in tests. + ALWAYS use t.Context() when a test function needs a context. + +Before: +```go +func TestFoo(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := doSomething(ctx) +} +``` +After: +```go +func TestFoo(t *testing.T) { + ctx := t.Context() + result := doSomething(ctx) +} +``` + +- `omitzero` not `omitempty` in JSON struct tags. + ALWAYS use omitzero for time.Duration, time.Time, structs, slices, maps. + +Before: +```go +type Config struct { + Timeout time.Duration `json:"timeout,omitempty"` // doesn't work for Duration! +} +``` +After: +```go +type Config struct { + Timeout time.Duration `json:"timeout,omitzero"` +} +``` + +- `b.Loop()` not `for i := 0; i < b.N; i++` in benchmarks. + ALWAYS use b.Loop() for the main loop in benchmark functions. + +Before: +```go +func BenchmarkFoo(b *testing.B) { + for i := 0; i < b.N; i++ { + doWork() + } +} +``` +After: +```go +func BenchmarkFoo(b *testing.B) { + for b.Loop() { + doWork() + } +} +``` + +- `strings.SplitSeq` not `strings.Split` when iterating. + ALWAYS use SplitSeq/FieldsSeq when iterating over split results in a for-range loop. + +Before: +```go +for _, part := range strings.Split(s, ",") { + process(part) +} +``` +After: +```go +for part := range strings.SplitSeq(s, ",") { + process(part) +} +``` +Also: `strings.FieldsSeq`, `bytes.SplitSeq`, `bytes.FieldsSeq`. + +### Go 1.25+ + +- `wg.Go(fn)` not `wg.Add(1)` + `go func() { defer wg.Done(); ... }()`. + ALWAYS use wg.Go() when spawning goroutines with sync.WaitGroup. + +Before: +```go +var wg sync.WaitGroup +for _, item := range items { + wg.Add(1) + go func() { + defer wg.Done() + process(item) + }() +} +wg.Wait() +``` +After: +```go +var wg sync.WaitGroup +for _, item := range items { + wg.Go(func() { + process(item) + }) +} +wg.Wait() +``` + +### Go 1.26+ + +- `new(val)` not `x := val; &x` — returns pointer to any value. + Go 1.26 extends new() to accept expressions, not just types. + Type is inferred: new(0) → *int, new("s") → *string, new(T{}) → *T. + DO NOT use `x := val; &x` pattern — always use new(val) directly. + DO NOT use redundant casts like new(int(0)) — just write new(0). + Common use case: struct fields with pointer types. + +Before: +```go +timeout := 30 +debug := true +cfg := Config{ + Timeout: &timeout, + Debug: &debug, +} +``` +After: +```go +cfg := Config{ + Timeout: new(30), // *int + Debug: new(true), // *bool +} +``` + +- `errors.AsType[T](err)` not `errors.As(err, &target)`. + ALWAYS use errors.AsType when checking if error matches a specific type. + +Before: +```go +var pathErr *os.PathError +if errors.As(err, &pathErr) { + handle(pathErr) +} +``` +After: +```go +if pathErr, ok := errors.AsType[*os.PathError](err); ok { + handle(pathErr) +} +``` diff --git a/auth/login.go b/auth/login.go index 15ab49c..e9d32e3 100644 --- a/auth/login.go +++ b/auth/login.go @@ -8,8 +8,8 @@ import ( "regexp" "strings" - "jwts/client" - "jwts/config" + "schedule_converter/client" + "schedule_converter/config" ) // 登录 diff --git a/captcha/recognize.go b/captcha/recognize.go index 859a8b9..be07714 100644 --- a/captcha/recognize.go +++ b/captcha/recognize.go @@ -1,26 +1,18 @@ package captcha import ( - "bytes" - "encoding/base64" - "encoding/json" "fmt" "io" - "log" "net/http" "os" - "regexp" "strings" - "time" - "jwts/client" - "jwts/config" - "jwts/models" + "schedule_converter/client" + "schedule_converter/config" "github.com/PuerkitoBio/goquery" ) -// 保存验证码图片 func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) { fmt.Println("[2] 获取验证码...") @@ -50,16 +42,13 @@ func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) { return body, nil } -// 识别验证码 - 使用固定验证码用于测试 func Recognize(imagePath string) (string, error) { fmt.Println("[3] 使用固定验证码...") - // 固定验证码用于测试 return "....", nil } -// 获取并识别验证码 -func GetAndRecognize(client *http.Client) (string, error) { - _, err := SaveCaptchaImage(client) +func GetAndRecognize(httpClient *http.Client) (string, error) { + _, err := SaveCaptchaImage(httpClient) if err != nil { return "", err } @@ -73,196 +62,6 @@ func GetAndRecognize(client *http.Client) (string, error) { return captchaCode, nil } -// 用于解析HTML(登录页面) func ParseHTML(html string) (*goquery.Document, error) { return goquery.NewDocumentFromReader(strings.NewReader(html)) } - -// RecognizeScheduleFromFile 从图片文件识别课表 -func RecognizeScheduleFromFile(imagePath string) ([]models.Course, error) { - fmt.Printf("[识别] 正在读取图片: %s\n", imagePath) - - imageData, err := os.ReadFile(imagePath) - if err != nil { - return nil, fmt.Errorf("读取图片失败: %v", err) - } - - imageBase64 := base64.StdEncoding.EncodeToString(imageData) - fmt.Printf("[识别] 图片大小: %d bytes\n", len(imageData)) - - return callScheduleAPI(imageBase64) -} - -// RecognizeScheduleFromBytes 从图片数据识别课表 -func RecognizeScheduleFromBytes(imageData []byte) ([]models.Course, error) { - fmt.Printf("[识别] 正在处理图片数据, 大小: %d bytes\n", len(imageData)) - - imageBase64 := base64.StdEncoding.EncodeToString(imageData) - - return callScheduleAPI(imageBase64) -} - -// RecognizeScheduleFromBase64 从base64数据识别课表 -func RecognizeScheduleFromBase64(imageBase64 string) ([]models.Course, error) { - fmt.Printf("[识别] 正在处理base64图片数据, 长度: %d\n", len(imageBase64)) - - return callScheduleAPI(imageBase64) -} - -// callScheduleAPI 调用API识别课表(带重试机制) -func callScheduleAPI(imageBase64 string) ([]models.Course, error) { - maxRetries := 3 - for retry := 0; retry < maxRetries; retry++ { - if retry > 0 { - fmt.Printf("[识别] 第 %d 次重试...\n", retry+1) - time.Sleep(5 * time.Second) - } - - courses, err := callScheduleAPISingle(imageBase64) - if err == nil { - return courses, nil - } - - fmt.Printf("[识别] 尝试 %d/%d 失败: %v\n", retry+1, maxRetries, err) - - if !strings.Contains(err.Error(), "504") { - return nil, err - } - } - - return nil, fmt.Errorf("已达到最大重试次数") -} - -// callScheduleAPISingle 单次API调用 -func callScheduleAPISingle(imageBase64 string) ([]models.Course, error) { - if config.RecognizerAPIKey == "" { - return nil, fmt.Errorf("RECOGNIZER_API_KEY 未配置") - } - - falseVal := false - zero := 0 - apiReq := models.APIRequest{ - Model: config.RecognizerModel, - MaxTokens: 4000, - EnableThinking: &falseVal, - ThinkingBudget: &zero, - Messages: []models.Message{ - { - Role: "user", - Content: []models.ContentItem{ - { - Type: "text", - Text: `请解析以下课表图片,提取所有课程信息并以JSON数组格式返回。 - -JSON格式要求: -[{"name":"课程名","teacher":"老师名","room":"教室","schedule":[{"day":"星期X","section":[节次数组],"weeks":[周数数组]}]}] - -注意事项: -1. day使用"星期一"到"星期日" -2. section节次:上午1-2节为[1,2],上午3-4节为[3,4],下午5-6节为[5,6],下午7-8节为[7,8],晚上9-10节为[9,10],晚上11-12节为[11,12] -3. weeks周数:格式如[1,2,3,4,5,6,7,8]或[9,10,11,12,13,14,15,16] -4. 只返回JSON数组,不要任何其他文字。`, - }, - { - Type: "image_url", - ImageURL: &models.ImageURL{ - URL: "data:image/png;base64," + imageBase64, - }, - }, - }, - }, - }, - } - - jsonData, err := json.Marshal(apiReq) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequest("POST", config.RecognizerAPIURL, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, err - } - - httpReq.Header = http.Header{ - "Content-Type": []string{"application/json"}, - "Authorization": []string{"Bearer " + config.RecognizerAPIKey}, - } - - fmt.Println("[识别] 正在调用API进行识别...") - resp, err := client.RecognizerClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("API调用失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API调用失败: %d, %s", resp.StatusCode, string(respBody)) - } - - var apiResp models.APIResponse - err = json.NewDecoder(resp.Body).Decode(&apiResp) - if err != nil { - return nil, fmt.Errorf("API响应解析失败: %v", err) - } - - if len(apiResp.Choices) == 0 { - return nil, fmt.Errorf("API返回为空") - } - - var resultText string - switch c := apiResp.Choices[0].Message.Content.(type) { - case string: - resultText = c - case []interface{}: - for _, item := range c { - if itemMap, ok := item.(map[string]interface{}); ok { - if text, ok := itemMap["text"].(string); ok { - resultText = text - break - } - } - } - } - - fmt.Println("[识别] API响应已接收,正在解析...") - fmt.Printf("[识别] 原始响应: %s\n", resultText) - - var jsonMatch string - - jsonBlockMatch := regexp.MustCompile("(?s)```json\\s*(\\[[\\s\\S]*?\\])\\s*```").FindStringSubmatch(resultText) - if len(jsonBlockMatch) > 1 { - jsonMatch = jsonBlockMatch[1] - fmt.Println("[识别] 从json代码块中提取JSON") - } else { - jsonBlockMatch = regexp.MustCompile("(?s)```\\s*(\\[[\\s\\S]*?\\])\\s*```").FindStringSubmatch(resultText) - if len(jsonBlockMatch) > 1 { - jsonMatch = jsonBlockMatch[1] - fmt.Println("[识别] 从代码块中提取JSON") - } else { - jsonMatch = regexp.MustCompile(`(\[[\s\S]*\])`).FindString(resultText) - if jsonMatch == "" { - return nil, fmt.Errorf("未找到JSON数据,原始响应: %s", resultText) - } - fmt.Println("[识别] 从文本中提取JSON") - } - } - - var courses []models.Course - err = json.Unmarshal([]byte(jsonMatch), &courses) - if err != nil { - return nil, fmt.Errorf("JSON解析失败: %v, 数据: %s", err, jsonMatch) - } - - fmt.Printf("[识别] 成功解析 %d 门课程\n", len(courses)) - return courses, nil -} - -func init() { - if config.RecognizerAPIKey == "" { - log.Println("警告: RECOGNIZER_API_KEY 未设置,图片识别功能将无法使用") - } - - log.Println("captcha模块已初始化") -} diff --git a/client/http.go b/client/http.go index 5fbafad..b4120d1 100644 --- a/client/http.go +++ b/client/http.go @@ -1,36 +1,40 @@ package client import ( - "fmt" - "log" + "log/slog" "net/http" "net/http/cookiejar" "net/url" "strings" - "time" + "sync" - "jwts/config" + "schedule_converter/config" ) -var Client *http.Client - -var RecognizerClient *http.Client +var ( + Client *http.Client + RecognizerClient *http.Client + once sync.Once +) func InitClient() { - jar, err := cookiejar.New(nil) - if err != nil { - log.Fatalf("Failed to create cookie jar: %v", err) - } - Client = &http.Client{ - Jar: jar, - } + once.Do(func() { + jar, err := cookiejar.New(nil) + if err != nil { + slog.Error("创建 cookie jar 失败", "error", err) + panic(err) + } + Client = &http.Client{Jar: jar} - RecognizerClient = &http.Client{ - Timeout: 120 * time.Second, - } + cfg := config.Get() + RecognizerClient = &http.Client{ + Timeout: cfg.Recognizer.Timeout, + } + + slog.Info("HTTP 客户端初始化完成") + }) } -// 设置请求头 func GetHeaders() http.Header { return http.Header{ "User-Agent": []string{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, @@ -39,56 +43,58 @@ func GetHeaders() http.Header { } } -// 获取带Content-Type的请求头 func GetHeadersWithContentType(contentType string) http.Header { headers := GetHeaders() - headers["Content-Type"] = []string{contentType} + headers.Set("Content-Type", contentType) return headers } -// 步骤1: 获取初始Cookie func GetInitialCookies() error { - fmt.Println("[1] 访问登录页面获取初始Cookie...") + return GetInitialCookiesWithClient(Client) +} - req, err := http.NewRequest("GET", config.BaseURL+"/loginNOCAS", nil) +func GetInitialCookiesWithClient(httpClient *http.Client) error { + slog.Info("访问登录页面获取初始Cookie") + + cfg := config.Get() + req, err := http.NewRequest("GET", cfg.BaseURL+"/loginNOCAS", nil) if err != nil { return err } req.Header = GetHeaders() - resp, err := Client.Do(req) + resp, err := httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() - fmt.Printf(" 登录页面状态码: %d\n", resp.StatusCode) - PrintCookies() + slog.Info("登录页面访问完成", "status_code", resp.StatusCode) + logCookies(httpClient) return nil } -// 打印Cookies -func PrintCookies() { - fmt.Printf(" 当前Cookies: ") - for _, cookie := range Client.Jar.Cookies(parseURL(config.BaseURL)) { - fmt.Printf("%s=%s; ", cookie.Name, cookie.Value) +func logCookies(httpClient *http.Client) { + cfg := config.Get() + cookies := httpClient.Jar.Cookies(parseURL(cfg.BaseURL)) + + var cookieStrs []string + for _, cookie := range cookies { + cookieStrs = append(cookieStrs, cookie.Name+"="+cookie.Value) } - fmt.Println() + slog.Debug("当前Cookies", "cookies", strings.Join(cookieStrs, "; ")) } -// 解析URL func parseURL(rawURL string) *url.URL { u, _ := url.Parse(rawURL) return u } -// 检查登录是否成功 func CheckLoginSuccess(body string) bool { return strings.Contains(body, "本科教学管理服务系统") } -// 检查是否验证码错误 func IsCaptchaError(body string) bool { return strings.Contains(body, "验证码") } diff --git a/config/config.go b/config/config.go index 8f05893..e455836 100644 --- a/config/config.go +++ b/config/config.go @@ -1,27 +1,101 @@ package config import ( + "log/slog" "os" + "strconv" + "sync" + "time" ) -// 配置变量 +type Config struct { + BaseURL string + Username string + Password string + Semester string + TWFID string + Recognizer RecognizerConfig +} + +type RecognizerConfig struct { + APIURL string + APIKey string + Model string + Timeout time.Duration +} + var ( - BaseURL = getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn") - Username = getEnv("JWTS_USERNAME", "") - Password = getEnv("JWTS_PASSWORD", "") - Semester = getEnv("JWTS_SEMESTER", "2025-20262") - TWFID = getEnv("JWTS_TWFID", "") - - RecognizerAPIURL = getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions") - RecognizerAPIKey = getEnv("RECOGNIZER_API_KEY", "") - RecognizerModel = getEnv("RECOGNIZER_MODEL", "qwen3.5-plus") + cfg *Config + cfgOnce sync.Once ) -// getEnv 从环境变量获取配置,提供默认值 +func Load() *Config { + cfgOnce.Do(func() { + cfg = &Config{ + BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"), + Username: getEnv("JWTS_USERNAME", ""), + Password: getEnv("JWTS_PASSWORD", ""), + Semester: getEnv("JWTS_SEMESTER", "2025-20262"), + TWFID: getEnv("JWTS_TWFID", ""), + Recognizer: RecognizerConfig{ + APIURL: getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions"), + APIKey: getEnv("RECOGNIZER_API_KEY", ""), + Model: getEnv("RECOGNIZER_MODEL", "qwen3.5-plus"), + Timeout: getEnvDuration("RECOGNIZER_TIMEOUT", 120*time.Second), + }, + } + + if cfg.Recognizer.APIKey == "" { + slog.Warn("RECOGNIZER_API_KEY not set, image recognition will not work") + } + }) + return cfg +} + +func Get() *Config { + if cfg == nil { + return Load() + } + return cfg +} + func getEnv(key, defaultValue string) string { - value := os.Getenv(key) - if value == "" { + val := os.Getenv(key) + if val == "" { return defaultValue } - return value + return val } + +func getEnvInt(key string, defaultValue int) int { + val := os.Getenv(key) + if val == "" { + return defaultValue + } + if i, err := strconv.Atoi(val); err == nil { + return i + } + return defaultValue +} + +func getEnvDuration(key string, defaultValue time.Duration) time.Duration { + val := os.Getenv(key) + if val == "" { + return defaultValue + } + if d, err := time.ParseDuration(val); err == nil { + return d + } + return defaultValue +} + +var ( + BaseURL = Get().BaseURL + Username = Get().Username + Password = Get().Password + Semester = Get().Semester + TWFID = Get().TWFID + RecognizerAPIURL = Get().Recognizer.APIURL + RecognizerAPIKey = Get().Recognizer.APIKey + RecognizerModel = Get().Recognizer.Model +) diff --git a/go.mod b/go.mod index 38b3021..233ff10 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module jwts +module schedule_converter go 1.25.7 diff --git a/grpc/client.go b/grpc/client.go index e571871..8100dcc 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -8,7 +8,7 @@ import ( "sync" "time" - pb "jwts/proto/runner" + pb "schedule_converter/proto/runner" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" diff --git a/grpc/handler.go b/grpc/handler.go index bc5cd56..2dc50ba 100644 --- a/grpc/handler.go +++ b/grpc/handler.go @@ -1,262 +1,161 @@ package grpc import ( + "context" "encoding/json" "fmt" - "log" + "log/slog" "net/http" "net/http/cookiejar" "net/url" - "strings" "sync" "time" - pb "jwts/proto/runner" + pb "schedule_converter/proto/runner" - "jwts/auth" - "jwts/captcha" - "jwts/config" - "jwts/models" - "jwts/parser" + "schedule_converter/auth" + "schedule_converter/captcha" + "schedule_converter/client" + "schedule_converter/config" + "schedule_converter/models" + "schedule_converter/parser" + apperr "schedule_converter/pkg/errors" ) -// TaskHandler 任务处理器 type TaskHandler struct { - cancelledTasks map[string]bool - mu sync.RWMutex + cancelledTasks sync.Map } -// NewTaskHandler 创建新的任务处理器 func NewTaskHandler() *TaskHandler { - return &TaskHandler{ - cancelledTasks: make(map[string]bool), - } + return &TaskHandler{} } -// Execute 执行任务 func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult { + log := slog.With("task_id", task.TaskId, "task_type", task.Type.String()) + log.Info("开始执行任务") + result := &pb.TaskResult{ TaskId: task.TaskId, CompletedAt: time.Now().UnixMilli(), } - // 检查任务是否已取消 if h.IsCancelled(task.TaskId) { result.Status = pb.TaskStatus_TASK_STATUS_CANCELLED result.ErrorMessage = "任务已取消" + log.Info("任务已取消") return result } + var err error + var data []byte + switch task.Type { case pb.TaskType_TASK_TYPE_GET_SCHEDULE: - data, err := h.getSchedule(task.Payload) - if err != nil { - result.Status = pb.TaskStatus_TASK_STATUS_FAILED - result.ErrorMessage = err.Error() - } else { - result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS - result.Data = data - } + data, err = h.getSchedule(context.Background(), task.Payload) case pb.TaskType_TASK_TYPE_LOGIN: - data, err := h.login(task.Payload) - if err != nil { - result.Status = pb.TaskStatus_TASK_STATUS_FAILED - result.ErrorMessage = err.Error() - } else { - result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS - result.Data = data - } + data, err = h.login(context.Background(), task.Payload) default: result.Status = pb.TaskStatus_TASK_STATUS_FAILED result.ErrorMessage = fmt.Sprintf("未知的任务类型: %v", task.Type) + log.Warn("未知的任务类型") + return result + } + + if err != nil { + result.Status = pb.TaskStatus_TASK_STATUS_FAILED + result.ErrorMessage = err.Error() + log.Error("任务执行失败", "error", err) + } else { + result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS + result.Data = data + log.Info("任务执行成功") } return result } -// CancelTask 取消任务 func (h *TaskHandler) CancelTask(taskID string) { - h.mu.Lock() - defer h.mu.Unlock() - h.cancelledTasks[taskID] = true - log.Printf("任务已标记为取消: %s", taskID) + h.cancelledTasks.Store(taskID, true) + slog.Info("任务已标记为取消", "task_id", taskID) } -// IsCancelled 检查任务是否已取消 func (h *TaskHandler) IsCancelled(taskID string) bool { - h.mu.RLock() - defer h.mu.RUnlock() - return h.cancelledTasks[taskID] + v, ok := h.cancelledTasks.Load(taskID) + return ok && v.(bool) } -// getSchedule 获取课表 -func (h *TaskHandler) getSchedule(payload []byte) ([]byte, error) { +func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) { var req pb.GetSchedulePayload if err := json.Unmarshal(payload, &req); err != nil { - return nil, fmt.Errorf("解析任务载荷失败: %w", err) + return nil, apperr.New("parse_payload", err, "解析任务载荷失败") } - semester := strings.TrimSpace(req.Semester) - if semester == "" { - semester = config.Semester - } + log := slog.With("username", req.Username, "semester", req.Semester) + log.Info("获取课表任务") - log.Printf("获取课表: 用户=%s, 学期=%s", req.Username, semester) - - // 创建新的 HTTP 客户端 - jar, err := cookiejar.New(nil) + httpClient, err := createHTTPClient() if err != nil { - return nil, fmt.Errorf("创建 cookie jar 失败: %w", err) - } - httpClient := &http.Client{ - Jar: jar, + return nil, err } - // 设置 TWFID cookie (使用默认配置) - setTWFIDCookie(httpClient, config.BaseURL, "") + cfg := config.Get() + setTWFIDCookie(httpClient, cfg.BaseURL, "") - // 步骤1: 获取初始 Cookie - if err := getInitialCookies(httpClient); err != nil { - return nil, fmt.Errorf("获取初始 Cookie 失败: %w", err) - } - - // 重试登录 - maxRetries := 3 - var loginSuccess bool - for attempt := 1; attempt <= maxRetries; attempt++ { - log.Printf("尝试登录 (第%d次)...", attempt) - - // 获取并识别验证码 - captchaCode, err := captcha.GetAndRecognize(httpClient) - if err != nil { - log.Printf(" 验证码获取失败: %v", err) - continue - } - - // 登录 - success, err := loginWithCredentials(httpClient, captchaCode, req.Username, req.Password) - if err != nil { - log.Printf(" 登录失败: %v", err) - continue - } - - if success { - loginSuccess = true - break - } - - log.Println(" 登录失败,3秒后重试...") - time.Sleep(3 * time.Second) - } - - if !loginSuccess { - return nil, fmt.Errorf("登录失败,已重试 %d 次", maxRetries) - } - - // 获取课表 - courses, err := parser.FetchScheduleWithClient(httpClient, semester) + courses, err := executeLoginAndFetchSchedule(ctx, httpClient, req.Username, req.Password, req.Semester) if err != nil { - return nil, fmt.Errorf("获取课表失败: %w", err) + return nil, err } - // 构建结果 result := &pb.ScheduleResultData{ StudentId: req.Username, - Semester: semester, + Semester: req.Semester, Courses: convertCourses(courses), } + log.Info("课表获取成功", "course_count", len(courses)) return json.Marshal(result) } -// login 登录验证 -func (h *TaskHandler) login(payload []byte) ([]byte, error) { +func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) { var req pb.LoginPayload if err := json.Unmarshal(payload, &req); err != nil { - return nil, fmt.Errorf("解析任务载荷失败: %w", err) + return nil, apperr.New("parse_payload", err, "解析任务载荷失败") } - log.Printf("登录验证: 用户=%s", req.Username) + log := slog.With("username", req.Username) + log.Info("登录任务") - // 创建新的 HTTP 客户端 + httpClient, err := createHTTPClient() + if err != nil { + return nil, err + } + + cfg := config.Get() + setTWFIDCookie(httpClient, cfg.BaseURL, req.Twfid) + + if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { + return nil, err + } + + log.Info("登录成功") + return json.Marshal(map[string]any{ + "success": true, + "message": "登录成功", + }) +} + +func createHTTPClient() (*http.Client, error) { jar, err := cookiejar.New(nil) if err != nil { - return nil, fmt.Errorf("创建 cookie jar 失败: %w", err) + return nil, apperr.New("create_jar", err, "创建 cookie jar 失败") } - httpClient := &http.Client{ - Jar: jar, - } - - // 设置 TWFID cookie - setTWFIDCookie(httpClient, config.BaseURL, req.Twfid) - - // 步骤1: 获取初始 Cookie - if err := getInitialCookies(httpClient); err != nil { - return nil, fmt.Errorf("获取初始 Cookie 失败: %w", err) - } - - // 重试登录 - maxRetries := 3 - for attempt := 1; attempt <= maxRetries; attempt++ { - log.Printf("尝试登录 (第%d次)...", attempt) - - // 获取并识别验证码 - captchaCode, err := captcha.GetAndRecognize(httpClient) - if err != nil { - log.Printf(" 验证码获取失败: %v", err) - continue - } - - // 登录 - success, err := loginWithCredentials(httpClient, captchaCode, req.Username, req.Password) - if err != nil { - log.Printf(" 登录失败: %v", err) - continue - } - - if success { - return json.Marshal(map[string]interface{}{ - "success": true, - "message": "登录成功", - }) - } - - log.Println(" 登录失败,3秒后重试...") - time.Sleep(3 * time.Second) - } - - return nil, fmt.Errorf("登录失败,已重试 %d 次", maxRetries) + return &http.Client{Jar: jar}, nil } -// getInitialCookies 获取初始 Cookie -func getInitialCookies(httpClient *http.Client) error { - log.Println("[1] 访问登录页面获取初始 Cookie...") - - req, err := http.NewRequest("GET", config.BaseURL+"/loginNOCAS", nil) - if err != nil { - return err - } - req.Header = getHeaders() - - resp, err := httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - log.Printf(" 登录页面状态码: %d", resp.StatusCode) - return nil -} - -// loginWithCredentials 使用凭据登录 -func loginWithCredentials(httpClient *http.Client, captchaCode, username, password string) (bool, error) { - return auth.LoginWithClient(httpClient, captchaCode, username, password) -} - -// setTWFIDCookie 设置 TWFID Cookie func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { + cfg := config.Get() if twfid == "" { - twfid = config.TWFID + twfid = cfg.TWFID } u, _ := url.Parse(baseURL) httpClient.Jar.SetCookies(u, []*http.Cookie{ @@ -264,38 +163,84 @@ func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { }) } -// getHeaders 获取请求头 -func getHeaders() http.Header { - return http.Header{ - "User-Agent": []string{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, - "Accept": []string{"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"}, - "Accept-Language": []string{"zh-CN,zh;q=0.9,en;q=0.8"}, +func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error { + log := slog.With("username", username) + + if err := client.GetInitialCookiesWithClient(httpClient); err != nil { + return apperr.New("get_cookies", err, "获取初始 Cookie 失败") } + + cfg := config.Get() + _ = cfg.Recognizer.Timeout + + for attempt := 1; attempt <= 3; attempt++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + log.Info("尝试登录", "attempt", attempt, "max_retries", 3) + + captchaCode, err := captcha.GetAndRecognize(httpClient) + if err != nil { + log.Warn("验证码获取失败", "error", err, "attempt", attempt) + time.Sleep(3 * time.Second) + continue + } + + success, err := auth.LoginWithClient(httpClient, captchaCode, username, password) + if err != nil { + log.Warn("登录请求失败", "error", err, "attempt", attempt) + time.Sleep(3 * time.Second) + continue + } + + if success { + return nil + } + + log.Warn("登录验证失败", "attempt", attempt) + time.Sleep(3 * time.Second) + } + + return apperr.ErrLoginFailed +} + +func executeLoginAndFetchSchedule(ctx context.Context, httpClient *http.Client, username, password, semester string) ([]models.Course, error) { + if err := executeLogin(ctx, httpClient, username, password); err != nil { + return nil, err + } + + cfg := config.Get() + if semester == "" { + semester = cfg.Semester + } + + return parser.FetchScheduleWithClient(httpClient, semester) } -// convertCourses 转换课程格式 func convertCourses(courses []models.Course) []*pb.Course { - var result []*pb.Course - for _, c := range courses { - var schedule []*pb.ScheduleTime - for _, s := range c.Schedule { - schedule = append(schedule, &pb.ScheduleTime{ + result := make([]*pb.Course, len(courses)) + for i, c := range courses { + schedule := make([]*pb.ScheduleTime, len(c.Schedule)) + for j, s := range c.Schedule { + schedule[j] = &pb.ScheduleTime{ Day: s.Day, Sections: convertToInt32(s.Section), Weeks: convertToInt32(s.Weeks), - }) + } } - result = append(result, &pb.Course{ + result[i] = &pb.Course{ Name: c.Name, Teacher: c.Teacher, Room: c.Room, Schedule: schedule, - }) + } } return result } -// convertToInt32 将 []int 转换为 []int32 func convertToInt32(arr []int) []int32 { result := make([]int32, len(arr)) for i, v := range arr { diff --git a/main.go b/main.go index 1ec9770..7ab0bc2 100644 --- a/main.go +++ b/main.go @@ -4,20 +4,18 @@ import ( "context" "flag" "io" - "log" + "log/slog" "net/http" "os" "os/signal" "syscall" "time" - "jwts/auth" - "jwts/captcha" - "jwts/client" - "jwts/config" - grpcClient "jwts/grpc" - "jwts/models" - "jwts/parser" + "schedule_converter/client" + "schedule_converter/config" + grpcClient "schedule_converter/grpc" + "schedule_converter/pkg/logger" + "schedule_converter/service" "github.com/gin-gonic/gin" ) @@ -25,8 +23,17 @@ import ( func main() { mode := flag.String("mode", "http", "运行模式: http 或 grpc") grpcAddr := flag.String("grpc-addr", "localhost:50051", "gRPC 服务器地址") + debug := flag.Bool("debug", false, "启用调试模式") flag.Parse() + if *debug { + logger.SetLevel(slog.LevelDebug) + logger.SetTextHandler() + } + + cfg := config.Load() + slog.Info("配置加载完成", "mode", *mode, "base_url", cfg.BaseURL) + if *mode == "grpc" { runGRPCClient(*grpcAddr) } else { @@ -34,7 +41,6 @@ func main() { } } -// runGRPCClient 运行 gRPC 客户端模式 func runGRPCClient(serverAddr string) { runnerID := os.Getenv("RUNNER_ID") if runnerID == "" { @@ -47,7 +53,7 @@ func runGRPCClient(serverAddr string) { version = "1.0.0" } - log.Printf("启动 gRPC 客户端, Runner ID: %s, 版本: %s", runnerID, version) + slog.Info("启动 gRPC 客户端", "runner_id", runnerID, "version", version) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -56,8 +62,8 @@ func runGRPCClient(serverAddr string) { signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) go func() { - <-sigChan - log.Println("正在关闭...") + sig := <-sigChan + slog.Info("收到信号,正在关闭...", "signal", sig) cancel() }() @@ -66,161 +72,147 @@ func runGRPCClient(serverAddr string) { for { select { case <-ctx.Done(): - log.Println("客户端已停止") + slog.Info("客户端已停止") return default: } - client := grpcClient.NewClient(serverAddr, runnerID, version) + c := grpcClient.NewClient(serverAddr, runnerID, version) handler := grpcClient.NewTaskHandler() - client.SetHandler(handler) + c.SetHandler(handler) - if err := client.Connect(ctx); err != nil { - log.Printf("连接失败: %v, %v 后重试...", err, reconnectInterval) + if err := c.Connect(ctx); err != nil { + slog.Warn("连接失败,稍后重试", "error", err, "retry_after", reconnectInterval) time.Sleep(reconnectInterval) continue } heartbeatCtx, heartbeatCancel := context.WithCancel(ctx) - go client.HeartbeatLoop(heartbeatCtx, 30*time.Second) + go c.HeartbeatLoop(heartbeatCtx, 30*time.Second) - err := client.Run(ctx) + err := c.Run(ctx) heartbeatCancel() - client.Close() + c.Close() if ctx.Err() != nil { return } if err != nil { - log.Printf("连接断开: %v, %v 后重连...", err, reconnectInterval) + slog.Warn("连接断开,稍后重连", "error", err, "retry_after", reconnectInterval) } else { - log.Printf("连接已关闭, %v 后重连...", reconnectInterval) + slog.Info("连接已关闭,稍后重连", "retry_after", reconnectInterval) } time.Sleep(reconnectInterval) } } -// runHTTPServer 运行 HTTP 服务器模式 func runHTTPServer() { - // 初始化客户端 client.InitClient() - r := gin.Default() + r := gin.New() + r.Use(gin.Recovery()) + r.Use(requestLogger()) - // 健康检查接口 r.GET("/ping", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "message": "pong", - }) + c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) - // 课表图片识别接口 - r.POST("/recognize", func(c *gin.Context) { - file, header, err := c.Request.FormFile("image") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "请上传图片文件", "details": err.Error()}) - return - } - defer file.Close() + r.POST("/recognize", handleRecognize) + r.POST("/task", handleTask) - log.Printf("收到图片上传: %s, 大小: %d bytes", header.Filename, header.Size) - - if header.Size > 10*1024*1024 { - c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大", "max_size": "10MB"}) - return - } - - imageData, err := io.ReadAll(file) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "details": err.Error()}) - return - } - - courses, err := captcha.RecognizeScheduleFromBytes(imageData) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "识别失败", "details": err.Error()}) - return - } - - models.PrintCourses(courses) - - err = models.SaveToJSON(courses, "schedule_result.json") - if err != nil { - log.Printf("保存JSON失败: %v", err) - } - - c.JSON(http.StatusOK, gin.H{ - "message": "识别成功", - "courses": courses, - "count": len(courses), - }) - }) - - // 任务处理接口 - r.POST("/task", func(c *gin.Context) { - // 这里可以根据请求体参数决定执行什么任务 - // 目前默认执行原有的登录和获取课表逻辑 - if config.Username == "" || config.Password == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "环境变量未设置"}) - return - } - - // 执行任务 - err := runTask() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "任务执行成功"}) - }) - - // 启动服务 - log.Println("Server starting on :8080") + slog.Info("HTTP 服务器启动", "port", 8080) if err := r.Run(":8080"); err != nil { - log.Fatalf("Failed to run server: %v", err) + slog.Error("服务器启动失败", "error", err) + os.Exit(1) } } -func runTask() error { - // 步骤1: 获取初始Cookie - err := client.GetInitialCookies() +func requestLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + + if query != "" { + path = path + "?" + query + } + + slog.Info("HTTP请求", + "method", c.Request.Method, + "path", path, + "status", status, + "latency", latency.String(), + "client_ip", c.ClientIP(), + ) + } +} + +func handleRecognize(c *gin.Context) { + file, header, err := c.Request.FormFile("image") if err != nil { - log.Printf("获取Cookie失败: %v", err) - return err + slog.Warn("图片上传失败", "error", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "请上传图片文件", "details": err.Error()}) + return + } + defer file.Close() + + slog.Info("收到图片上传", "filename", header.Filename, "size", header.Size) + + if header.Size > 10*1024*1024 { + c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大", "max_size": "10MB"}) + return } - // 重试登录 - maxRetries := 3 - for attempt := 1; attempt <= maxRetries; attempt++ { - log.Printf("尝试登录 (第%d次)...", attempt) - - // 获取并识别验证码 - captchaCode, err := captcha.GetAndRecognize(client.Client) - if err != nil { - log.Printf(" 验证码获取失败: %v", err) - continue - } - - // 登录 - success, err := auth.Login(captchaCode) - if err != nil { - log.Printf(" 登录失败: %v", err) - continue - } - - if success { - // 获取课表 - _, err = parser.FetchSchedule(client.Client) - if err != nil { - log.Printf("获取课表失败: %v", err) - } - return nil - } - - log.Println(" 登录失败,3秒后重试...") + imageData, err := io.ReadAll(file) + if err != nil { + slog.Error("读取图片失败", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "details": err.Error()}) + return } - return nil + recognizer := service.NewRecognizerService() + courses, err := recognizer.RecognizeFromBytes(c.Request.Context(), imageData) + if err != nil { + slog.Error("识别失败", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "识别失败", "details": err.Error()}) + return + } + + service.PrintCourses(courses) + + if err := service.SaveToJSON(courses, "schedule_result.json"); err != nil { + slog.Warn("保存JSON失败", "error", err) + } + + c.JSON(http.StatusOK, gin.H{ + "message": "识别成功", + "courses": courses, + "count": len(courses), + }) +} + +func handleTask(c *gin.Context) { + cfg := config.Get() + if cfg.Username == "" || cfg.Password == "" { + slog.Warn("环境变量未设置") + c.JSON(http.StatusInternalServerError, gin.H{"error": "环境变量未设置"}) + return + } + + scheduleSvc := service.NewScheduleService() + _, err := scheduleSvc.FetchSchedule(c.Request.Context(), cfg.Username, cfg.Password, cfg.Semester) + if err != nil { + slog.Error("任务执行失败", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + slog.Info("任务执行成功") + c.JSON(http.StatusOK, gin.H{"message": "任务执行成功"}) } diff --git a/models/api.go b/models/api.go index bc9af61..29981b7 100644 --- a/models/api.go +++ b/models/api.go @@ -11,8 +11,8 @@ type APIRequest struct { // Message 消息结构 type Message struct { - Role string `json:"role"` - Content interface{} `json:"content"` + Role string `json:"role"` + Content any `json:"content"` } // ContentItem 内容项 diff --git a/parser/schedule.go b/parser/schedule.go index a49d8a1..8b2309b 100644 --- a/parser/schedule.go +++ b/parser/schedule.go @@ -11,9 +11,9 @@ import ( "strconv" "strings" - "jwts/client" - "jwts/config" - "jwts/models" + "schedule_converter/client" + "schedule_converter/config" + "schedule_converter/models" "github.com/PuerkitoBio/goquery" ) @@ -54,12 +54,6 @@ func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models return nil, err } - if err := os.WriteFile("grkb.html", body, 0644); err != nil { - fmt.Printf(" 警告: 无法保存课表页面: %v\n", err) - } else { - fmt.Println(" 课表页面已保存到: grkb.html") - } - // 解析课表 courses, err := ParseScheduleHTML(string(body)) if err != nil { diff --git a/parser/schedule_test.go b/parser/schedule_test.go index 91df08d..465a22b 100644 --- a/parser/schedule_test.go +++ b/parser/schedule_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "jwts/models" + "schedule_converter/models" ) func TestParseScheduleHTML_FromTestHTML_MergedWeeks(t *testing.T) { diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 0000000..c6f818d --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,58 @@ +package errors + +import ( + "errors" + "fmt" +) + +var ( + ErrInvalidConfig = errors.New("invalid configuration") + ErrLoginFailed = errors.New("login failed") + ErrCaptchaFailed = errors.New("captcha recognition failed") + ErrScheduleFailed = errors.New("failed to fetch schedule") + ErrRecognitionFailed = errors.New("schedule recognition failed") + ErrUnauthorized = errors.New("unauthorized access") + ErrTimeout = errors.New("operation timeout") + ErrConnectionFailed = errors.New("connection failed") +) + +type ScheduleError struct { + Op string + Err error + Msg string +} + +func (e *ScheduleError) Error() string { + if e.Msg != "" { + return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) + } + return fmt.Sprintf("%s: %v", e.Op, e.Err) +} + +func (e *ScheduleError) Unwrap() error { + return e.Err +} + +func New(op string, err error, msg string) error { + return &ScheduleError{ + Op: op, + Err: err, + Msg: msg, + } +} + +func Wrap(err error, msg string) error { + return fmt.Errorf("%s: %w", msg, err) +} + +func Join(errs ...error) error { + return errors.Join(errs...) +} + +func Is(err, target error) bool { + return errors.Is(err, target) +} + +func As(err error, target any) bool { + return errors.As(err, target) +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..75744e0 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,55 @@ +package logger + +import ( + "log/slog" + "os" +) + +var ( + defaultLevel = slog.LevelInfo + logger *slog.Logger +) + +func init() { + opts := &slog.HandlerOptions{ + Level: defaultLevel, + } + logger = slog.New(slog.NewJSONHandler(os.Stdout, opts)) + slog.SetDefault(logger) +} + +func SetLevel(level slog.Level) { + opts := &slog.HandlerOptions{ + Level: level, + } + logger = slog.New(slog.NewJSONHandler(os.Stdout, opts)) + slog.SetDefault(logger) +} + +func SetTextHandler() { + opts := &slog.HandlerOptions{ + Level: defaultLevel, + } + logger = slog.New(slog.NewTextHandler(os.Stdout, opts)) + slog.SetDefault(logger) +} + +func Debug(msg string, args ...any) { + slog.Debug(msg, args...) +} + +func Info(msg string, args ...any) { + slog.Info(msg, args...) +} + +func Warn(msg string, args ...any) { + slog.Warn(msg, args...) +} + +func Error(msg string, args ...any) { + slog.Error(msg, args...) +} + +func With(args ...any) *slog.Logger { + return slog.With(args...) +} diff --git a/proto/runner/runner.pb.go b/proto/runner/runner.pb.go index 8b821a2..ff501b4 100644 --- a/proto/runner/runner.pb.go +++ b/proto/runner/runner.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 -// protoc v6.33.1 +// protoc-gen-go v1.36.9 +// protoc v3.19.4 // source: proto/runner/runner.proto package runner @@ -1735,7 +1735,7 @@ const file_proto_runner_runner_proto_rawDesc = "" + "\x13TASK_STATUS_TIMEOUT\x10\x03\x12\x19\n" + "\x15TASK_STATUS_CANCELLED\x10\x042H\n" + "\tRunnerHub\x12;\n" + - "\aConnect\x12\x15.runner.StreamMessage\x1a\x15.runner.StreamMessage(\x010\x01B\x1aZ\x18jwts/proto/runner;runnerb\x06proto3" + "\aConnect\x12\x15.runner.StreamMessage\x1a\x15.runner.StreamMessage(\x010\x01B(Z&schedule_converter/proto/runner;runnerb\x06proto3" var ( file_proto_runner_runner_proto_rawDescOnce sync.Once diff --git a/proto/runner/runner.proto b/proto/runner/runner.proto index 33f35b8..0983f2a 100644 --- a/proto/runner/runner.proto +++ b/proto/runner/runner.proto @@ -2,7 +2,7 @@ syntax = "proto3"; package runner; -option go_package = "jwts/proto/runner;runner"; +option go_package = "schedule_converter/proto/runner;runner"; // ============================================================ // 服务定义 diff --git a/proto/runner/runner_grpc.pb.go b/proto/runner/runner_grpc.pb.go index 72b7c8d..7c1863e 100644 --- a/proto/runner/runner_grpc.pb.go +++ b/proto/runner/runner_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 -// - protoc v6.33.1 +// - protoc-gen-go-grpc v1.5.1 +// - protoc v3.19.4 // source: proto/runner/runner.proto package runner @@ -76,7 +76,7 @@ type RunnerHubServer interface { type UnimplementedRunnerHubServer struct{} func (UnimplementedRunnerHubServer) Connect(grpc.BidiStreamingServer[StreamMessage, StreamMessage]) error { - return status.Error(codes.Unimplemented, "method Connect not implemented") + return status.Errorf(codes.Unimplemented, "method Connect not implemented") } func (UnimplementedRunnerHubServer) mustEmbedUnimplementedRunnerHubServer() {} func (UnimplementedRunnerHubServer) testEmbeddedByValue() {} @@ -89,7 +89,7 @@ type UnsafeRunnerHubServer interface { } func RegisterRunnerHubServer(s grpc.ServiceRegistrar, srv RunnerHubServer) { - // If the following call panics, it indicates UnimplementedRunnerHubServer was + // If the following call pancis, it indicates UnimplementedRunnerHubServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. diff --git a/models/output.go b/service/output.go similarity index 84% rename from models/output.go rename to service/output.go index b7d719d..4c37746 100644 --- a/models/output.go +++ b/service/output.go @@ -1,12 +1,14 @@ -package models +package service import ( "encoding/json" "fmt" "os" + + "schedule_converter/models" ) -func SaveToJSON(courses []Course, filename string) error { +func SaveToJSON(courses []models.Course, filename string) error { jsonData, err := json.MarshalIndent(courses, "", " ") if err != nil { return err @@ -21,7 +23,7 @@ func SaveToJSON(courses []Course, filename string) error { return nil } -func PrintCourses(courses []Course) { +func PrintCourses(courses []models.Course) { fmt.Println("\n==================================================") fmt.Println("识别结果:") fmt.Println("==================================================") diff --git a/service/recognizer.go b/service/recognizer.go new file mode 100644 index 0000000..d6d7d91 --- /dev/null +++ b/service/recognizer.go @@ -0,0 +1,241 @@ +package service + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "schedule_converter/client" + "schedule_converter/config" + "schedule_converter/models" + apperr "schedule_converter/pkg/errors" +) + +var ( + jsonBlockRegex = regexp.MustCompile("(?s)```json\\s*(\\[[\\s\\S]*?\\])\\s*```") + codeBlockRegex = regexp.MustCompile("(?s)```\\s*(\\[[\\s\\S]*?\\])\\s*```") + jsonArrayRegex = regexp.MustCompile(`(\[[\s\S]*\])`) + regexPool = sync.Pool{ + New: func() any { + return nil + }, + } +) + +type RecognizerService struct { + client *http.Client + maxRetry int + retryWait time.Duration +} + +func NewRecognizerService() *RecognizerService { + return &RecognizerService{ + client: client.RecognizerClient, + maxRetry: 3, + retryWait: 5 * time.Second, + } +} + +func (r *RecognizerService) RecognizeFromFile(ctx context.Context, imagePath string) ([]models.Course, error) { + log := slog.With("image_path", imagePath) + log.Info("正在读取图片") + + imageData, err := os.ReadFile(imagePath) + if err != nil { + return nil, apperr.New("recognize_file", err, "读取图片失败") + } + + log.Info("图片读取完成", "size", len(imageData)) + return r.RecognizeFromBytes(ctx, imageData) +} + +func (r *RecognizerService) RecognizeFromBytes(ctx context.Context, imageData []byte) ([]models.Course, error) { + log := slog.With("size", len(imageData)) + log.Info("正在处理图片数据") + + imageBase64 := base64.StdEncoding.EncodeToString(imageData) + return r.callAPI(ctx, imageBase64) +} + +func (r *RecognizerService) RecognizeFromBase64(ctx context.Context, imageBase64 string) ([]models.Course, error) { + slog.Info("正在处理base64图片数据", "length", len(imageBase64)) + return r.callAPI(ctx, imageBase64) +} + +func (r *RecognizerService) callAPI(ctx context.Context, imageBase64 string) ([]models.Course, error) { + var lastErr error + for retry := range r.maxRetry { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + if retry > 0 { + slog.Info("重试中", "attempt", retry+1, "max_retry", r.maxRetry) + time.Sleep(r.retryWait) + } + + courses, err := r.callAPISingle(ctx, imageBase64) + if err == nil { + return courses, nil + } + + lastErr = err + slog.Warn("API调用失败", "attempt", retry+1, "error", err) + + if !strings.Contains(err.Error(), "504") { + return nil, err + } + } + + return nil, apperr.New("recognize_api", lastErr, "已达到最大重试次数") +} + +func (r *RecognizerService) callAPISingle(ctx context.Context, imageBase64 string) ([]models.Course, error) { + cfg := config.Get() + if cfg.Recognizer.APIKey == "" { + return nil, apperr.ErrRecognitionFailed + } + + prompt := `请解析以下课表图片,提取所有课程信息并以JSON数组格式返回。 + +JSON格式要求: +[{"name":"课程名","teacher":"老师名","room":"教室","schedule":[{"day":"星期X","section":[节次数组],"weeks":[周数数组]}]}] + +注意事项: +1. day使用"星期一"到"星期日" +2. section节次:上午1-2节为[1,2],上午3-4节为[3,4],下午5-6节为[5,6],下午7-8节为[7,8],晚上9-10节为[9,10],晚上11-12节为[11,12] +3. weeks周数:格式如[1,2,3,4,5,6,7,8]或[9,10,11,12,13,14,15,16] +4. 只返回JSON数组,不要任何其他文字。` + + falseVal := false + zero := 0 + apiReq := models.APIRequest{ + Model: cfg.Recognizer.Model, + MaxTokens: 4000, + EnableThinking: &falseVal, + ThinkingBudget: &zero, + Messages: []models.Message{ + { + Role: "user", + Content: []models.ContentItem{ + {Type: "text", Text: prompt}, + { + Type: "image_url", + ImageURL: &models.ImageURL{ + URL: "data:image/png;base64," + imageBase64, + }, + }, + }, + }, + }, + } + + jsonData, err := json.Marshal(apiReq) + if err != nil { + return nil, apperr.New("marshal_request", err, "") + } + + req, err := http.NewRequestWithContext(ctx, "POST", cfg.Recognizer.APIURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, apperr.New("create_request", err, "") + } + + req.Header = http.Header{ + "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer " + cfg.Recognizer.APIKey}, + } + + slog.Info("调用识别API") + resp, err := r.client.Do(req) + if err != nil { + return nil, apperr.New("api_call", err, "") + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API调用失败: %d, %s", resp.StatusCode, string(respBody)) + } + + var apiResp models.APIResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, apperr.New("decode_response", err, "") + } + + if len(apiResp.Choices) == 0 { + return nil, apperr.New("empty_response", apperr.ErrRecognitionFailed, "API返回为空") + } + + resultText := extractContent(apiResp.Choices[0].Message.Content) + slog.Debug("API响应接收完成", "response_length", len(resultText)) + + jsonMatch := extractJSON(resultText) + if jsonMatch == "" { + return nil, apperr.New("extract_json", apperr.ErrRecognitionFailed, + fmt.Sprintf("未找到JSON数据,原始响应: %s", resultText)) + } + + var courses []models.Course + if err := json.Unmarshal([]byte(jsonMatch), &courses); err != nil { + return nil, apperr.New("parse_json", err, fmt.Sprintf("数据: %s", jsonMatch)) + } + + slog.Info("识别完成", "course_count", len(courses)) + return courses, nil +} + +func extractContent(content any) string { + switch c := content.(type) { + case string: + return c + case []any: + for _, item := range c { + if itemMap, ok := item.(map[string]any); ok { + if text, ok := itemMap["text"].(string); ok { + return text + } + } + } + } + return "" +} + +func extractJSON(text string) string { + if match := jsonBlockRegex.FindStringSubmatch(text); len(match) > 1 { + slog.Debug("从json代码块中提取JSON") + return match[1] + } + if match := codeBlockRegex.FindStringSubmatch(text); len(match) > 1 { + slog.Debug("从代码块中提取JSON") + return match[1] + } + if match := jsonArrayRegex.FindString(text); match != "" { + slog.Debug("从文本中提取JSON") + return match + } + return "" +} + +func RecognizeScheduleFromFile(imagePath string) ([]models.Course, error) { + return NewRecognizerService().RecognizeFromFile(context.Background(), imagePath) +} + +func RecognizeScheduleFromBytes(imageData []byte) ([]models.Course, error) { + return NewRecognizerService().RecognizeFromBytes(context.Background(), imageData) +} + +func RecognizeScheduleFromBase64(imageBase64 string) ([]models.Course, error) { + return NewRecognizerService().RecognizeFromBase64(context.Background(), imageBase64) +} diff --git a/service/schedule.go b/service/schedule.go new file mode 100644 index 0000000..6935b49 --- /dev/null +++ b/service/schedule.go @@ -0,0 +1,124 @@ +package service + +import ( + "context" + "log/slog" + "net/http" + "time" + + "schedule_converter/auth" + "schedule_converter/captcha" + "schedule_converter/client" + "schedule_converter/config" + "schedule_converter/models" + "schedule_converter/parser" + apperr "schedule_converter/pkg/errors" +) + +type ScheduleService struct { + httpClient *http.Client + maxRetries int + retryDelay time.Duration +} + +type ScheduleOption func(*ScheduleService) + +func WithMaxRetries(n int) ScheduleOption { + return func(s *ScheduleService) { + s.maxRetries = n + } +} + +func WithRetryDelay(d time.Duration) ScheduleOption { + return func(s *ScheduleService) { + s.retryDelay = d + } +} + +func NewScheduleService(opts ...ScheduleOption) *ScheduleService { + s := &ScheduleService{ + httpClient: client.Client, + maxRetries: 3, + retryDelay: 3 * time.Second, + } + for _, opt := range opts { + opt(s) + } + return s +} + +func NewScheduleServiceWithClient(httpClient *http.Client, opts ...ScheduleOption) *ScheduleService { + s := &ScheduleService{ + httpClient: httpClient, + maxRetries: 3, + retryDelay: 3 * time.Second, + } + for _, opt := range opts { + opt(s) + } + return s +} + +func (s *ScheduleService) FetchSchedule(ctx context.Context, username, password, semester string) ([]models.Course, error) { + log := slog.With("username", username, "semester", semester) + + if err := client.GetInitialCookiesWithClient(s.httpClient); err != nil { + return nil, apperr.New("fetch_schedule", err, "获取Cookie失败") + } + + var lastErr error + for attempt := 1; attempt <= s.maxRetries; attempt++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + log.Info("尝试登录", "attempt", attempt, "max_retries", s.maxRetries) + + captchaCode, err := captcha.GetAndRecognize(s.httpClient) + if err != nil { + log.Warn("验证码获取失败", "error", err, "attempt", attempt) + lastErr = err + time.Sleep(s.retryDelay) + continue + } + + success, err := auth.LoginWithClient(s.httpClient, captchaCode, username, password) + if err != nil { + log.Warn("登录失败", "error", err, "attempt", attempt) + lastErr = err + time.Sleep(s.retryDelay) + continue + } + + if success { + if semester == "" { + semester = config.Semester + } + courses, err := parser.FetchScheduleWithClient(s.httpClient, semester) + if err != nil { + return nil, apperr.New("fetch_schedule", err, "获取课表失败") + } + log.Info("获取课表成功", "course_count", len(courses)) + return courses, nil + } + + log.Warn("登录验证失败,稍后重试", "attempt", attempt) + time.Sleep(s.retryDelay) + } + + return nil, apperr.New("fetch_schedule", lastErr, "登录失败,已重试") +} + +func (s *ScheduleService) FetchDefaultSchedule(ctx context.Context) ([]models.Course, error) { + return s.FetchSchedule(ctx, config.Username, config.Password, config.Semester) +} + +func (s *ScheduleService) FetchScheduleForUser(username, password, semester string) ([]models.Course, error) { + return s.FetchSchedule(context.Background(), username, password, semester) +} + +func (s *ScheduleService) FetchDefaultScheduleLegacy() ([]models.Course, error) { + return s.FetchScheduleForUser(config.Username, config.Password, config.Semester) +}