Compare commits
22 Commits
eebf239528
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04ad865bf2 | ||
|
|
8915fca841 | ||
|
|
4024f9eae0 | ||
|
|
c4c3320f51 | ||
|
|
58301bbed0 | ||
|
|
ab37aeb2fc | ||
|
|
ae9297c11c | ||
|
|
1cd0c655f8 | ||
|
|
accc6567f2 | ||
|
|
ee5c36f3ac | ||
|
|
d64f2fa20b | ||
|
|
478ec3e3ab | ||
|
|
a04e5c2c50 | ||
|
|
766eb9cda4 | ||
|
|
a168e6f2e4 | ||
|
|
d0ff0a28df | ||
|
|
74438c1f7f | ||
|
|
294151cd40 | ||
|
|
01ea9ac77d | ||
|
|
a5c9bc9f87 | ||
|
|
ed1d252fab | ||
|
|
9addc89a66 |
44
.github/workflows/build.yml
vendored
Normal file
44
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
create:
|
||||
tag:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.25'
|
||||
APP_NAME: schedule_converter
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build Linux AMD64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ${{ env.APP_NAME }}-linux-amd64 .
|
||||
|
||||
- name: Build Windows AMD64
|
||||
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ${{ env.APP_NAME }}-windows-amd64.exe .
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.APP_NAME }}
|
||||
path: |
|
||||
${{ env.APP_NAME }}-linux-amd64
|
||||
${{ env.APP_NAME }}-windows-amd64.exe
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,8 @@
|
||||
converter
|
||||
converter.exe
|
||||
test_build.exe
|
||||
test_final.exe
|
||||
*.exe
|
||||
grkb.html
|
||||
test.html
|
||||
schedule_result.json
|
||||
|
||||
115
.opencode/package-lock.json
generated
Normal file
115
.opencode/package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.4.3",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz",
|
||||
"integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
291
.opencode/skills/use-modern-go/SKILL.md
Normal file
291
.opencode/skills/use-modern-go/SKILL.md
Normal file
@@ -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)
|
||||
}
|
||||
```
|
||||
@@ -1,28 +1,27 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"jwts/client"
|
||||
"jwts/config"
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/config"
|
||||
)
|
||||
|
||||
// 登录
|
||||
func Login(captchaCode string) (bool, error) {
|
||||
return LoginWithClient(client.Client, captchaCode, config.Username, config.Password)
|
||||
}
|
||||
var nameRegexp = regexp.MustCompile(`(\w+)同学`)
|
||||
|
||||
// LoginWithClient 使用指定的 HTTP 客户端和凭据登录
|
||||
func LoginWithClient(httpClient *http.Client, captchaCode, username, password string) (bool, error) {
|
||||
fmt.Println("[4] 执行登录...")
|
||||
// LoginWithClient 使用指定的 HTTP 客户端和凭据登录。
|
||||
func LoginWithClient(ctx context.Context, httpClient *http.Client, captchaCode, username, password string) (bool, error) {
|
||||
log := slog.With("username", username)
|
||||
log.Debug("执行登录")
|
||||
|
||||
data := fmt.Sprintf("usercode=%s&password=%s&code=%s", username, password, captchaCode)
|
||||
req, err := http.NewRequest("POST", config.BaseURL+"/login", strings.NewReader(data))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", config.Get().BaseURL+"/login", strings.NewReader(data))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -40,29 +39,20 @@ func LoginWithClient(httpClient *http.Client, captchaCode, username, password st
|
||||
return false, err
|
||||
}
|
||||
|
||||
if client.CheckLoginSuccess(string(body)) {
|
||||
fmt.Println(" ✓ 登录成功!")
|
||||
|
||||
// 提取用户名
|
||||
nameMatch := regexp.MustCompile(`(\w+)同学`).FindStringSubmatch(string(body))
|
||||
if len(nameMatch) > 1 {
|
||||
fmt.Printf(" 欢迎, %s!\n", nameMatch[1])
|
||||
}
|
||||
|
||||
// 保存登录后的页面
|
||||
if err := os.WriteFile("login_result.html", body, 0644); err != nil {
|
||||
fmt.Printf(" 警告: 无法保存登录页面: %v\n", err)
|
||||
bodyStr := string(body)
|
||||
if client.CheckLoginSuccess(bodyStr) {
|
||||
if match := nameRegexp.FindStringSubmatch(bodyStr); len(match) > 1 {
|
||||
log.Info("登录成功", "name", match[1])
|
||||
} else {
|
||||
fmt.Println(" 登录后页面已保存到: login_result.html")
|
||||
log.Info("登录成功")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
fmt.Println(" ✗ 登录失败")
|
||||
if client.IsCaptchaError(string(body)) {
|
||||
fmt.Println(" 错误: 验证码错误")
|
||||
if client.IsCaptchaError(bodyStr) {
|
||||
log.Warn("登录失败:验证码错误")
|
||||
} else {
|
||||
log.Warn("登录失败")
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -1,72 +1,7 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"jwts/client"
|
||||
"jwts/config"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
// 保存验证码图片
|
||||
func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
|
||||
fmt.Println("[2] 获取验证码...")
|
||||
|
||||
req, err := http.NewRequest("GET", config.BaseURL+"/captchaImage", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header = client.GetHeaders()
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.WriteFile("captcha.png", body, 0644); err != nil {
|
||||
fmt.Printf(" 警告: 无法保存验证码图片: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" 验证码已保存到: captcha.png")
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 识别验证码 - 使用固定验证码用于测试
|
||||
func Recognize(imagePath string) (string, error) {
|
||||
fmt.Println("[3] 使用固定验证码...")
|
||||
// 固定验证码用于测试
|
||||
return "....", nil
|
||||
}
|
||||
|
||||
// 获取并识别验证码
|
||||
func GetAndRecognize(client *http.Client) (string, error) {
|
||||
_, err := SaveCaptchaImage(client)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
captchaCode, err := Recognize("captcha.png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Printf(" 识别结果: %s\n", captchaCode)
|
||||
return captchaCode, nil
|
||||
}
|
||||
|
||||
// 用于解析HTML(登录页面)
|
||||
func ParseHTML(html string) (*goquery.Document, error) {
|
||||
return goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||
// GetCaptchaCode 返回固定验证码占位值。
|
||||
// 教务系统验证码不再需要 AI 识别,登录时直接提交固定占位值 "...."。
|
||||
func GetCaptchaCode() string {
|
||||
return "...."
|
||||
}
|
||||
|
||||
75
client/encoding.go
Normal file
75
client/encoding.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// DecodeResponseBody 读取 HTTP 响应体并按 Content-Type 进行字符集解码(处理 GBK)。
|
||||
func DecodeResponseBody(resp *http.Response) (string, error) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decodeBody(body, resp.Header.Get("Content-Type")), nil
|
||||
}
|
||||
|
||||
func decodeBody(body []byte, contentType string) string {
|
||||
lowerCT := strings.ToLower(contentType)
|
||||
if strings.Contains(lowerCT, "charset=gb") || strings.Contains(lowerCT, "charset=gb2312") || strings.Contains(lowerCT, "charset=gbk") {
|
||||
decoded, err := decodeGBK(body)
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
if isValidUTF8(body) {
|
||||
return string(body)
|
||||
}
|
||||
|
||||
decoded, err := decodeGBK(body)
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func decodeGBK(data []byte) (string, error) {
|
||||
reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder())
|
||||
decoded, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
func isValidUTF8(data []byte) bool {
|
||||
for i := 0; i < len(data); i++ {
|
||||
if data[i]&0x80 != 0 {
|
||||
if data[i]&0xE0 == 0xC0 {
|
||||
if i+1 >= len(data) || data[i+1]&0xC0 != 0x80 {
|
||||
return false
|
||||
}
|
||||
i++
|
||||
} else if data[i]&0xF0 == 0xE0 {
|
||||
if i+2 >= len(data) || data[i+1]&0xC0 != 0x80 || data[i+2]&0xC0 != 0x80 {
|
||||
return false
|
||||
}
|
||||
i += 2
|
||||
} else if data[i]&0xF8 == 0xF0 {
|
||||
if i+3 >= len(data) || data[i+1]&0xC0 != 0x80 || data[i+2]&0xC0 != 0x80 || data[i+3]&0xC0 != 0x80 {
|
||||
return false
|
||||
}
|
||||
i += 3
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
47
client/fetch.go
Normal file
47
client/fetch.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"schedule_converter/config"
|
||||
)
|
||||
|
||||
// FetchHTML 发送 HTTP 请求并返回解码后的 HTML 字符串(处理 GBK 字符集)。
|
||||
// method 为 "GET" 或 "POST";path 拼接到 config.BaseURL 之后;body 为请求体(GET 传 nil)。
|
||||
// referer 为可选的 Referer 路径(拼接到 BaseURL 之后),不需要时传 ""。
|
||||
func FetchHTML(ctx context.Context, httpClient *http.Client, method, path string, body io.Reader, contentType, referer string) (string, error) {
|
||||
resp, err := doRequest(ctx, httpClient, method, path, body, contentType, referer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("请求 %s 失败, 状态码: %d", path, resp.StatusCode)
|
||||
}
|
||||
|
||||
return DecodeResponseBody(resp)
|
||||
}
|
||||
|
||||
// doRequest 构造并执行请求,设置 header(可选 Referer),透传 ctx。
|
||||
func doRequest(ctx context.Context, httpClient *http.Client, method, path string, body io.Reader, contentType, referer string) (*http.Response, error) {
|
||||
url := config.Get().BaseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
req.Header = GetHeadersWithContentType(contentType)
|
||||
} else {
|
||||
req.Header = GetHeaders()
|
||||
}
|
||||
if referer != "" {
|
||||
req.Header.Set("Referer", config.Get().BaseURL+referer)
|
||||
}
|
||||
|
||||
return httpClient.Do(req)
|
||||
}
|
||||
@@ -1,89 +1,52 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"jwts/config"
|
||||
"schedule_converter/config"
|
||||
)
|
||||
|
||||
// 全局HTTP客户端
|
||||
var Client *http.Client
|
||||
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
// 初始化HTTP客户端
|
||||
func InitClient() {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create cookie jar: %v", err)
|
||||
}
|
||||
Client = &http.Client{
|
||||
Jar: jar,
|
||||
}
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
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"},
|
||||
"User-Agent": []string{defaultUserAgent},
|
||||
"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"},
|
||||
}
|
||||
}
|
||||
|
||||
// 获取带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...")
|
||||
func GetInitialCookiesWithClient(httpClient *http.Client) error {
|
||||
slog.Info("访问登录页面获取初始Cookie")
|
||||
|
||||
req, err := http.NewRequest("GET", config.BaseURL+"/loginNOCAS", nil)
|
||||
req, err := http.NewRequest("GET", config.Get().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)
|
||||
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)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// 解析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, "验证码")
|
||||
}
|
||||
|
||||
@@ -2,22 +2,48 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 配置变量
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
Semester string
|
||||
TWFID string
|
||||
}
|
||||
|
||||
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", "")
|
||||
cfg *Config
|
||||
cfgOnce sync.Once
|
||||
)
|
||||
|
||||
// getEnv 从环境变量获取配置,提供默认值
|
||||
func Load() *Config {
|
||||
cfgOnce.Do(func() {
|
||||
cfg = &Config{
|
||||
BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"),
|
||||
Semester: getEnv("JWTS_SEMESTER", ""),
|
||||
TWFID: getEnv("JWTS_TWFID", ""),
|
||||
}
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
var (
|
||||
BaseURL = Get().BaseURL
|
||||
Semester = Get().Semester
|
||||
TWFID = Get().TWFID
|
||||
)
|
||||
|
||||
30
go.mod
30
go.mod
@@ -1,43 +1,17 @@
|
||||
module jwts
|
||||
module schedule_converter
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.70.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
|
||||
)
|
||||
|
||||
79
go.sum
79
go.sum
@@ -2,86 +2,18 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
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/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
|
||||
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
||||
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
|
||||
@@ -92,18 +24,12 @@ go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiy
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
||||
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
|
||||
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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=
|
||||
@@ -133,7 +59,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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=
|
||||
@@ -174,7 +99,3 @@ google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
|
||||
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
135
grpc/client.go
135
grpc/client.go
@@ -4,27 +4,30 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "jwts/proto/runner"
|
||||
pb "schedule_converter/proto/runner"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const defaultHeartbeatInterval = 30 * time.Second
|
||||
|
||||
// Client gRPC 客户端
|
||||
type Client struct {
|
||||
serverAddr string
|
||||
runnerID string
|
||||
version string
|
||||
|
||||
mu sync.RWMutex
|
||||
conn *grpc.ClientConn
|
||||
stream pb.RunnerHub_ConnectClient
|
||||
mu sync.Mutex
|
||||
handler *TaskHandler
|
||||
connected bool
|
||||
|
||||
// 配置
|
||||
handler *TaskHandler
|
||||
heartbeatInterval time.Duration
|
||||
}
|
||||
|
||||
@@ -34,7 +37,7 @@ func NewClient(serverAddr, runnerID, version string) *Client {
|
||||
serverAddr: serverAddr,
|
||||
runnerID: runnerID,
|
||||
version: version,
|
||||
heartbeatInterval: 30 * time.Second,
|
||||
heartbeatInterval: defaultHeartbeatInterval,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,57 +48,71 @@ func (c *Client) SetHandler(handler *TaskHandler) {
|
||||
|
||||
// Connect 连接到 gRPC 服务器
|
||||
func (c *Client) Connect(ctx context.Context) error {
|
||||
log.Printf("正在连接到 gRPC 服务器: %s", c.serverAddr)
|
||||
slog.Info("正在连接 gRPC 服务器", "server", c.serverAddr)
|
||||
|
||||
conn, err := grpc.Dial(c.serverAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接失败: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
|
||||
client := pb.NewRunnerHubClient(conn)
|
||||
stream, err := client.Connect(ctx)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("创建流失败: %w", err)
|
||||
}
|
||||
c.stream = stream
|
||||
|
||||
// 发送注册请求
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.stream = stream
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.register(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("gRPC 连接成功")
|
||||
slog.Info("gRPC 连接已建立")
|
||||
return nil
|
||||
}
|
||||
|
||||
// register 发送注册请求
|
||||
func (c *Client) register() error {
|
||||
capabilities := make(map[string]string, len(supportedTaskTypes()))
|
||||
for _, t := range supportedTaskTypes() {
|
||||
capabilities[t.String()] = "true"
|
||||
}
|
||||
msg := &pb.StreamMessage{
|
||||
Message: &pb.StreamMessage_Register{
|
||||
Register: &pb.RegisterRequest{
|
||||
RunnerId: c.runnerID,
|
||||
Version: c.version,
|
||||
Capabilities: map[string]string{
|
||||
"TASK_TYPE_GET_SCHEDULE": "true",
|
||||
"TASK_TYPE_LOGIN": "true",
|
||||
},
|
||||
Capabilities: capabilities,
|
||||
},
|
||||
},
|
||||
}
|
||||
return c.stream.Send(msg)
|
||||
return c.sendMessage(msg)
|
||||
}
|
||||
|
||||
// Run 运行主循环,接收消息
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
defer c.markDisconnected()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
msg, err := c.stream.Recv()
|
||||
}
|
||||
|
||||
stream := c.getStream()
|
||||
if stream == nil {
|
||||
return fmt.Errorf("流未连接")
|
||||
}
|
||||
|
||||
msg, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
log.Println("服务器关闭了连接")
|
||||
slog.Info("服务器关闭了连接")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -104,70 +121,58 @@ func (c *Client) Run(ctx context.Context) error {
|
||||
|
||||
c.handleMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage 处理接收到的消息
|
||||
func (c *Client) handleMessage(msg *pb.StreamMessage) {
|
||||
switch m := msg.Message.(type) {
|
||||
case *pb.StreamMessage_RegisterResponse:
|
||||
log.Printf("注册成功: %s, 会话ID: %s, 心跳间隔: %d秒",
|
||||
m.RegisterResponse.Message,
|
||||
m.RegisterResponse.SessionId,
|
||||
m.RegisterResponse.HeartbeatInterval)
|
||||
slog.Info("注册成功",
|
||||
"message", m.RegisterResponse.Message,
|
||||
"session_id", m.RegisterResponse.SessionId,
|
||||
"heartbeat_interval_sec", m.RegisterResponse.HeartbeatInterval,
|
||||
)
|
||||
if m.RegisterResponse.HeartbeatInterval > 0 {
|
||||
c.heartbeatInterval = time.Duration(m.RegisterResponse.HeartbeatInterval) * time.Second
|
||||
}
|
||||
case *pb.StreamMessage_HeartbeatAck:
|
||||
// 心跳确认,可以用于计算延迟
|
||||
log.Printf("心跳确认,延迟: %dms", m.HeartbeatAck.Latency)
|
||||
slog.Debug("心跳确认", "latency_ms", m.HeartbeatAck.Latency)
|
||||
case *pb.StreamMessage_Task:
|
||||
log.Printf("收到任务: %s, 类型: %v", m.Task.TaskId, m.Task.Type)
|
||||
slog.Info("收到任务", "task_id", m.Task.TaskId, "type", m.Task.Type.String())
|
||||
go c.handleTask(m.Task)
|
||||
case *pb.StreamMessage_TaskCancel:
|
||||
log.Printf("收到取消任务请求: %s, 原因: %s", m.TaskCancel.TaskId, m.TaskCancel.Reason)
|
||||
slog.Info("收到取消任务请求", "task_id", m.TaskCancel.TaskId, "reason", m.TaskCancel.Reason)
|
||||
if c.handler != nil {
|
||||
c.handler.CancelTask(m.TaskCancel.TaskId)
|
||||
}
|
||||
default:
|
||||
log.Printf("收到未知消息类型: %T", msg.Message)
|
||||
slog.Warn("收到未知消息类型", "type", fmt.Sprintf("%T", msg.Message))
|
||||
}
|
||||
}
|
||||
|
||||
// handleTask 处理任务
|
||||
func (c *Client) handleTask(task *pb.Task) {
|
||||
if c.handler == nil {
|
||||
log.Println("错误: 未设置任务处理器")
|
||||
slog.Error("未设置任务处理器")
|
||||
return
|
||||
}
|
||||
|
||||
result := c.handler.Execute(task)
|
||||
|
||||
c.mu.Lock()
|
||||
err := c.stream.Send(&pb.StreamMessage{
|
||||
Message: &pb.StreamMessage_Result{
|
||||
Result: result,
|
||||
},
|
||||
err := c.sendMessage(&pb.StreamMessage{
|
||||
Message: &pb.StreamMessage_Result{Result: result},
|
||||
})
|
||||
c.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("发送任务结果失败: %v", err)
|
||||
} else {
|
||||
log.Printf("任务结果已发送: %s, 状态: %v", result.TaskId, result.Status)
|
||||
slog.Error("发送任务结果失败", "task_id", result.TaskId, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("任务结果已发送", "task_id", result.TaskId, "status", result.Status.String())
|
||||
}
|
||||
|
||||
// sendHeartbeat 发送心跳
|
||||
func (c *Client) sendHeartbeat() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.stream == nil {
|
||||
return fmt.Errorf("流未连接")
|
||||
}
|
||||
|
||||
return c.stream.Send(&pb.StreamMessage{
|
||||
return c.sendMessage(&pb.StreamMessage{
|
||||
Message: &pb.StreamMessage_Heartbeat{
|
||||
Heartbeat: &pb.Heartbeat{
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
@@ -187,7 +192,7 @@ func (c *Client) HeartbeatLoop(ctx context.Context, interval time.Duration) {
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.sendHeartbeat(); err != nil {
|
||||
log.Printf("发送心跳失败: %v", err)
|
||||
slog.Warn("发送心跳失败", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,11 +200,43 @@ func (c *Client) HeartbeatLoop(ctx context.Context, interval time.Duration) {
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.connected = false
|
||||
if c.stream != nil {
|
||||
c.stream.CloseSend()
|
||||
_ = c.stream.CloseSend()
|
||||
}
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
c.stream = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMessage 加锁发送消息,确保 stream 仍然可用
|
||||
func (c *Client) sendMessage(msg *pb.StreamMessage) error {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if !c.connected || c.stream == nil {
|
||||
return fmt.Errorf("流未连接")
|
||||
}
|
||||
return c.stream.Send(msg)
|
||||
}
|
||||
|
||||
// getStream 读锁获取 stream
|
||||
func (c *Client) getStream() pb.RunnerHub_ConnectClient {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.stream
|
||||
}
|
||||
|
||||
// markDisconnected 标记连接已断开,避免后续发送
|
||||
func (c *Client) markDisconnected() {
|
||||
c.mu.Lock()
|
||||
c.connected = false
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
335
grpc/handler.go
335
grpc/handler.go
@@ -1,305 +1,114 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "jwts/proto/runner"
|
||||
"schedule_converter/service"
|
||||
|
||||
"jwts/auth"
|
||||
"jwts/captcha"
|
||||
"jwts/config"
|
||||
"jwts/models"
|
||||
"jwts/parser"
|
||||
pb "schedule_converter/proto/runner"
|
||||
)
|
||||
|
||||
// TaskHandler 任务处理器
|
||||
type TaskHandler struct {
|
||||
cancelledTasks map[string]bool
|
||||
mu sync.RWMutex
|
||||
cancelledTasks sync.Map
|
||||
service *service.Service
|
||||
}
|
||||
|
||||
// NewTaskHandler 创建新的任务处理器
|
||||
func NewTaskHandler() *TaskHandler {
|
||||
return &TaskHandler{
|
||||
cancelledTasks: make(map[string]bool),
|
||||
return &TaskHandler{service: service.NewService()}
|
||||
}
|
||||
|
||||
func supportedTaskTypes() []pb.TaskType {
|
||||
return []pb.TaskType{
|
||||
pb.TaskType_TASK_TYPE_LOGIN,
|
||||
pb.TaskType_TASK_TYPE_GET_SCHEDULE,
|
||||
pb.TaskType_TASK_TYPE_GET_GRADES,
|
||||
pb.TaskType_TASK_TYPE_GET_EXAMS,
|
||||
pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM,
|
||||
pb.TaskType_TASK_TYPE_GET_CET,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 执行任务
|
||||
func unmarshalPayload(payload []byte, v any) error {
|
||||
if len(payload) == 0 {
|
||||
return fmt.Errorf("任务 payload 为空")
|
||||
}
|
||||
if err := json.Unmarshal(payload, v); err != nil {
|
||||
return fmt.Errorf("解析任务 payload 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
data, err := h.dispatch(ctx, task)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, errUnknownTaskType) {
|
||||
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
|
||||
result.ErrorMessage = err.Error()
|
||||
log.Warn("未知的任务类型")
|
||||
return result
|
||||
}
|
||||
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
|
||||
result.ErrorMessage = err.Error()
|
||||
log.Error("任务执行失败", "error", err)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS
|
||||
result.Data = data
|
||||
log.Info("任务执行成功")
|
||||
return result
|
||||
}
|
||||
|
||||
var errUnknownTaskType = fmt.Errorf("unknown task type")
|
||||
|
||||
func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, error) {
|
||||
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
|
||||
}
|
||||
return h.getSchedule(ctx, task.Payload)
|
||||
case pb.TaskType_TASK_TYPE_GET_GRADES:
|
||||
return h.getGrades(ctx, task.Payload)
|
||||
case pb.TaskType_TASK_TYPE_GET_EXAMS:
|
||||
return h.getExams(ctx, 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
|
||||
}
|
||||
return h.login(ctx, task.Payload)
|
||||
case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM:
|
||||
return h.getEmptyClassrooms(ctx, task.Payload)
|
||||
case pb.TaskType_TASK_TYPE_GET_CET:
|
||||
return h.getCET(ctx, task.Payload)
|
||||
default:
|
||||
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
|
||||
result.ErrorMessage = fmt.Sprintf("未知的任务类型: %v", task.Type)
|
||||
return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
// getSchedule 获取课表
|
||||
func (h *TaskHandler) getSchedule(payload []byte) ([]byte, error) {
|
||||
var req pb.GetSchedulePayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return nil, fmt.Errorf("解析任务载荷失败: %w", err)
|
||||
}
|
||||
|
||||
semester := strings.TrimSpace(req.Semester)
|
||||
if semester == "" {
|
||||
semester = config.Semester
|
||||
}
|
||||
|
||||
log.Printf("获取课表: 用户=%s, 学期=%s", req.Username, semester)
|
||||
|
||||
// 创建新的 HTTP 客户端
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 cookie jar 失败: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
// 设置 TWFID cookie (使用默认配置)
|
||||
setTWFIDCookie(httpClient, config.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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取课表失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建结果
|
||||
result := &pb.ScheduleResultData{
|
||||
StudentId: req.Username,
|
||||
Semester: semester,
|
||||
Courses: convertCourses(courses),
|
||||
}
|
||||
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
// login 登录验证
|
||||
func (h *TaskHandler) login(payload []byte) ([]byte, error) {
|
||||
var req pb.LoginPayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return nil, fmt.Errorf("解析任务载荷失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("登录验证: 用户=%s", req.Username)
|
||||
|
||||
// 创建新的 HTTP 客户端
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 cookie jar 失败: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if twfid == "" {
|
||||
twfid = config.TWFID
|
||||
}
|
||||
u, _ := url.Parse(baseURL)
|
||||
httpClient.Jar.SetCookies(u, []*http.Cookie{
|
||||
{Name: "TWFID", Value: twfid},
|
||||
})
|
||||
}
|
||||
|
||||
// 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"},
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
Day: s.Day,
|
||||
Sections: convertToInt32(s.Section),
|
||||
Weeks: convertToInt32(s.Weeks),
|
||||
})
|
||||
}
|
||||
result = append(result, &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 {
|
||||
result[i] = int32(v)
|
||||
}
|
||||
return result
|
||||
v, ok := h.cancelledTasks.Load(taskID)
|
||||
return ok && v.(bool)
|
||||
}
|
||||
|
||||
29
grpc/handler_cet.go
Normal file
29
grpc/handler_cet.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
)
|
||||
|
||||
func (h *TaskHandler) getCET(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
var req pb.GetCetPayload
|
||||
if err := unmarshalPayload(payload, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := slog.With("username", req.Username)
|
||||
log.Info("获取四六级成绩任务")
|
||||
|
||||
result, err := h.service.GetCET(ctx, &req)
|
||||
if err != nil {
|
||||
return nil, apperr.New("get_cet", err, "获取四六级成绩失败")
|
||||
}
|
||||
|
||||
log.Info("四六级成绩获取成功", "score_count", len(result.Scores))
|
||||
return json.Marshal(result)
|
||||
}
|
||||
29
grpc/handler_classroom.go
Normal file
29
grpc/handler_classroom.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
)
|
||||
|
||||
func (h *TaskHandler) getEmptyClassrooms(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
var req pb.GetEmptyClassroomPayload
|
||||
if err := unmarshalPayload(payload, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := slog.With("username", req.Username, "semester", req.Semester)
|
||||
log.Info("获取空教室任务")
|
||||
|
||||
result, err := h.service.GetEmptyClassrooms(ctx, &req)
|
||||
if err != nil {
|
||||
return nil, apperr.New("get_empty_classrooms", err, "获取空教室失败")
|
||||
}
|
||||
|
||||
log.Info("空教室获取成功", "classroom_count", len(result.Classrooms))
|
||||
return json.Marshal(result)
|
||||
}
|
||||
29
grpc/handler_exams.go
Normal file
29
grpc/handler_exams.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
)
|
||||
|
||||
func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
var req pb.GetExamsPayload
|
||||
if err := unmarshalPayload(payload, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := slog.With("username", req.Username, "semester", req.Semester)
|
||||
log.Info("获取考试安排任务")
|
||||
|
||||
result, err := h.service.GetExams(ctx, &req)
|
||||
if err != nil {
|
||||
return nil, apperr.New("get_exams", err, "获取考试安排失败")
|
||||
}
|
||||
|
||||
log.Info("考试安排获取成功", "exam_count", len(result.Exams))
|
||||
return json.Marshal(result)
|
||||
}
|
||||
29
grpc/handler_grades.go
Normal file
29
grpc/handler_grades.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
)
|
||||
|
||||
func (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
var req pb.GetGradesPayload
|
||||
if err := unmarshalPayload(payload, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := slog.With("username", req.Username, "semester", req.Semester)
|
||||
log.Info("获取成绩任务")
|
||||
|
||||
result, err := h.service.GetGrades(ctx, &req)
|
||||
if err != nil {
|
||||
return nil, apperr.New("get_grades", err, "获取成绩失败")
|
||||
}
|
||||
|
||||
log.Info("成绩获取成功", "grade_count", len(result.Grades))
|
||||
return json.Marshal(result)
|
||||
}
|
||||
31
grpc/handler_login.go
Normal file
31
grpc/handler_login.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
)
|
||||
|
||||
func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
var req pb.LoginPayload
|
||||
if err := unmarshalPayload(payload, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := slog.With("username", req.Username)
|
||||
log.Info("登录任务")
|
||||
|
||||
if _, err := h.service.Login(ctx, req.Username, req.Password, req.Twfid); err != nil {
|
||||
return nil, apperr.New("login", err, "登录失败")
|
||||
}
|
||||
|
||||
log.Info("登录成功")
|
||||
return json.Marshal(map[string]any{
|
||||
"success": true,
|
||||
"message": "登录成功",
|
||||
})
|
||||
}
|
||||
29
grpc/handler_schedule.go
Normal file
29
grpc/handler_schedule.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
)
|
||||
|
||||
func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) {
|
||||
var req pb.GetSchedulePayload
|
||||
if err := unmarshalPayload(payload, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := slog.With("username", req.Username, "semester", req.Semester)
|
||||
log.Info("获取课表任务")
|
||||
|
||||
result, err := h.service.GetSchedule(ctx, &req)
|
||||
if err != nil {
|
||||
return nil, apperr.New("get_schedule", err, "获取课表失败")
|
||||
}
|
||||
|
||||
log.Info("课表获取成功", "course_count", len(result.Courses))
|
||||
return json.Marshal(result)
|
||||
}
|
||||
195
main.go
195
main.go
@@ -3,160 +3,125 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"jwts/auth"
|
||||
"jwts/captcha"
|
||||
"jwts/client"
|
||||
"jwts/config"
|
||||
grpcClient "jwts/grpc"
|
||||
"jwts/parser"
|
||||
"schedule_converter/config"
|
||||
grpcClient "schedule_converter/grpc"
|
||||
"schedule_converter/pkg/logger"
|
||||
)
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
const (
|
||||
defaultGRPCAddr = "localhost:50051"
|
||||
runnerIDPrefix = "runner-"
|
||||
defaultVersion = "1.0.0"
|
||||
initialReconnectInterval = 5 * time.Second
|
||||
maxReconnectInterval = 60 * time.Second
|
||||
heartbeatInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
mode := flag.String("mode", "http", "运行模式: http 或 grpc")
|
||||
grpcAddr := flag.String("grpc-addr", "localhost:50051", "gRPC 服务器地址")
|
||||
grpcAddr := flag.String("grpc-addr", defaultGRPCAddr, "gRPC 服务器地址")
|
||||
debug := flag.Bool("debug", false, "启用调试模式")
|
||||
flag.Parse()
|
||||
|
||||
if *mode == "grpc" {
|
||||
runGRPCClient(*grpcAddr)
|
||||
} else {
|
||||
runHTTPServer()
|
||||
if *debug {
|
||||
logger.SetLevel(slog.LevelDebug)
|
||||
logger.SetTextHandler()
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
slog.Info("配置加载完成", "base_url", cfg.BaseURL)
|
||||
|
||||
runGRPCClient(*grpcAddr)
|
||||
}
|
||||
|
||||
func resolveRunnerID() string {
|
||||
if id := os.Getenv("RUNNER_ID"); id != "" {
|
||||
return id
|
||||
}
|
||||
if hostname, err := os.Hostname(); err == nil && hostname != "" {
|
||||
return runnerIDPrefix + hostname
|
||||
}
|
||||
return fmt.Sprintf("%s%d", runnerIDPrefix, os.Getpid())
|
||||
}
|
||||
|
||||
func resolveVersion() string {
|
||||
if v := os.Getenv("RUNNER_VERSION"); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVersion
|
||||
}
|
||||
|
||||
// runGRPCClient 运行 gRPC 客户端模式
|
||||
func runGRPCClient(serverAddr string) {
|
||||
runnerID := os.Getenv("RUNNER_ID")
|
||||
if runnerID == "" {
|
||||
hostname, _ := os.Hostname()
|
||||
runnerID = "runner-" + hostname
|
||||
}
|
||||
runnerID := resolveRunnerID()
|
||||
version := resolveVersion()
|
||||
|
||||
version := os.Getenv("RUNNER_VERSION")
|
||||
if version == "" {
|
||||
version = "1.0.0"
|
||||
}
|
||||
|
||||
log.Printf("启动 gRPC 客户端, Runner ID: %s, 版本: %s", runnerID, version)
|
||||
|
||||
client := grpcClient.NewClient(serverAddr, runnerID, version)
|
||||
handler := grpcClient.NewTaskHandler()
|
||||
client.SetHandler(handler)
|
||||
slog.Info("启动 gRPC 客户端", "runner_id", runnerID, "version", version)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// 处理信号
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
log.Printf("连接失败: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
log.Println("正在关闭...")
|
||||
client.Close()
|
||||
sig := <-sigChan
|
||||
slog.Info("收到信号,正在关闭...", "signal", sig)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// 启动心跳
|
||||
go client.HeartbeatLoop(ctx, 30*time.Second)
|
||||
retryCount := 0
|
||||
|
||||
// 运行主循环
|
||||
if err := client.Run(ctx); err != nil {
|
||||
log.Printf("客户端错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runHTTPServer 运行 HTTP 服务器模式
|
||||
func runHTTPServer() {
|
||||
// 初始化客户端
|
||||
client.InitClient()
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 健康检查接口
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "pong",
|
||||
})
|
||||
})
|
||||
|
||||
// 任务处理接口
|
||||
r.POST("/task", func(c *gin.Context) {
|
||||
// 这里可以根据请求体参数决定执行什么任务
|
||||
// 目前默认执行原有的登录和获取课表逻辑
|
||||
if config.Username == "" || config.Password == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "环境变量未设置"})
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("客户端已停止", "total_retries", retryCount)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// 执行任务
|
||||
err := runTask()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
retryCount++
|
||||
currentInterval := initialReconnectInterval
|
||||
if retryCount > 1 {
|
||||
currentInterval = min(initialReconnectInterval*time.Duration(retryCount-1), maxReconnectInterval)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "任务执行成功"})
|
||||
})
|
||||
slog.Info("正在连接服务器", "attempt", retryCount, "server", serverAddr)
|
||||
|
||||
// 启动服务
|
||||
log.Println("Server starting on :8080")
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatalf("Failed to run server: %v", err)
|
||||
}
|
||||
}
|
||||
c := grpcClient.NewClient(serverAddr, runnerID, version)
|
||||
handler := grpcClient.NewTaskHandler()
|
||||
c.SetHandler(handler)
|
||||
|
||||
func runTask() error {
|
||||
// 步骤1: 获取初始Cookie
|
||||
err := client.GetInitialCookies()
|
||||
if err != nil {
|
||||
log.Printf("获取Cookie失败: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 重试登录
|
||||
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)
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
slog.Warn("连接失败", "error", err, "attempt", retryCount, "retry_after", currentInterval)
|
||||
time.Sleep(currentInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
// 登录
|
||||
success, err := auth.Login(captchaCode)
|
||||
slog.Info("连接成功", "attempt", retryCount)
|
||||
retryCount = 0
|
||||
|
||||
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
|
||||
go c.HeartbeatLoop(heartbeatCtx, heartbeatInterval)
|
||||
|
||||
err := c.Run(ctx)
|
||||
heartbeatCancel()
|
||||
c.Close()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf(" 登录失败: %v", err)
|
||||
continue
|
||||
slog.Warn("连接断开", "error", err, "retry_after", initialReconnectInterval)
|
||||
} else {
|
||||
slog.Info("连接已关闭")
|
||||
}
|
||||
|
||||
if success {
|
||||
// 获取课表
|
||||
_, err = parser.FetchSchedule(client.Client)
|
||||
if err != nil {
|
||||
log.Printf("获取课表失败: %v", err)
|
||||
time.Sleep(initialReconnectInterval)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println(" 登录失败,3秒后重试...")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package models
|
||||
|
||||
// APIRequest API请求结构
|
||||
type APIRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// Message 消息结构
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
// ContentItem 内容项
|
||||
type ContentItem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
// ImageURL 图片URL
|
||||
type ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// APIResponse API响应结构
|
||||
type APIResponse struct {
|
||||
Choices []Choice `json:"choices"`
|
||||
}
|
||||
|
||||
// Choice 选择
|
||||
type Choice struct {
|
||||
Message Message `json:"message"`
|
||||
}
|
||||
10
models/empty_classroom.go
Normal file
10
models/empty_classroom.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
// EmptyClassroomEntry 空教室条目
|
||||
type EmptyClassroomEntry struct {
|
||||
Classroom string `json:"classroom"`
|
||||
Weekday string `json:"weekday"`
|
||||
Periods string `json:"periods"`
|
||||
Term string `json:"term"`
|
||||
Weeks string `json:"weeks"`
|
||||
}
|
||||
20
models/exam.go
Normal file
20
models/exam.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
// ExamType 考试类型
|
||||
type ExamType string
|
||||
|
||||
const (
|
||||
ExamTypeFinal ExamType = "final"
|
||||
ExamTypeMidterm ExamType = "midterm"
|
||||
ExamTypeMakeup ExamType = "makeup"
|
||||
)
|
||||
|
||||
// ExamEntry 考试条目
|
||||
type ExamEntry struct {
|
||||
Course string
|
||||
Date string
|
||||
Time string
|
||||
Week string
|
||||
Weekday string
|
||||
Type ExamType
|
||||
}
|
||||
33
models/grade.go
Normal file
33
models/grade.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
// ScoreType 成绩类型
|
||||
type ScoreType string
|
||||
|
||||
const (
|
||||
ScoreTypeFinal ScoreType = "final"
|
||||
ScoreTypeMidterm ScoreType = "midterm"
|
||||
)
|
||||
|
||||
// GradeEntry 成绩条目
|
||||
type GradeEntry struct {
|
||||
Term string
|
||||
Code string
|
||||
Name string
|
||||
Nature string
|
||||
Category string
|
||||
Credit string
|
||||
Score string
|
||||
GradePoint string
|
||||
IsExam string
|
||||
IsRetake bool
|
||||
}
|
||||
|
||||
// GPASummary 学分绩概要
|
||||
type GPASummary struct {
|
||||
AverageGPA string
|
||||
Rank string
|
||||
ExamTotalGPA string
|
||||
ExamTotalCredits string
|
||||
FailCredits string
|
||||
Semesters string
|
||||
}
|
||||
184
parser/cet.go
Normal file
184
parser/cet.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
// CETScore 四六级成绩条目。
|
||||
type CETScore struct {
|
||||
Level string // 等级:英语四级/英语六级
|
||||
ExamTime string // 考试时间:如 2025年06月
|
||||
TicketNum string // 准考证号
|
||||
Score string // 成绩
|
||||
}
|
||||
|
||||
// CETStudentInfo 四六级查询中的学生信息。
|
||||
type CETStudentInfo struct {
|
||||
StudentID string // 学号
|
||||
Name string // 姓名
|
||||
Gender string // 性别
|
||||
IDCard string // 身份证号
|
||||
Duration string // 学制
|
||||
Grade string // 年级
|
||||
College string // 学院
|
||||
Major string // 专业
|
||||
}
|
||||
|
||||
// CETResult 四六级查询结果。
|
||||
type CETResult struct {
|
||||
StudentInfo CETStudentInfo
|
||||
Scores []CETScore
|
||||
}
|
||||
|
||||
// cetScoreRegex 匹配四六级成绩行。
|
||||
// 示例:英语六级 时间 2025年06月 准考证号 372220251213105 成绩 445
|
||||
var cetScoreRegex = regexp.MustCompile(`(英语[四六]级)\s+时间\s*([0-9]{4}年[0-9]{1,2}月)\s+准考证号\s*([0-9A-Za-z]+)\s+成绩\s*([0-9]+|缺考|合格|不合格|优秀|良好|通过|未通过)`)
|
||||
|
||||
// FetchCETHTML 解析四六级成绩 HTML 页面。
|
||||
func ParseCETHTML(html string) (*CETResult, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &CETResult{}
|
||||
table := findCETTable(doc)
|
||||
if table.Length() == 0 {
|
||||
return nil, fmt.Errorf("未找到四六级成绩表格")
|
||||
}
|
||||
|
||||
parseCETStudentInfo(table, &result.StudentInfo)
|
||||
result.Scores = parseCETScores(table)
|
||||
if len(result.Scores) == 0 {
|
||||
result.Scores = parseCETScoresFromText(doc.Find(".jdcx_area").Text())
|
||||
}
|
||||
if len(result.Scores) == 0 {
|
||||
result.Scores = parseCETScoresFromText(doc.Find(".Contentbox").Text())
|
||||
}
|
||||
if len(result.Scores) == 0 {
|
||||
result.Scores = parseCETScoresFromText(doc.Text())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func findCETTable(doc *goquery.Document) *goquery.Selection {
|
||||
selectors := []string{
|
||||
"table.addlist",
|
||||
".jdcx_area table",
|
||||
"form#ff table",
|
||||
"table:contains('已有成绩')",
|
||||
}
|
||||
for _, selector := range selectors {
|
||||
table := doc.Find(selector).First()
|
||||
if table.Length() > 0 {
|
||||
return table
|
||||
}
|
||||
}
|
||||
return doc.Find("__not_found__")
|
||||
}
|
||||
|
||||
func parseCETStudentInfo(table *goquery.Selection, info *CETStudentInfo) {
|
||||
table.Find("tr").Each(func(_ int, row *goquery.Selection) {
|
||||
var label string
|
||||
row.Find("th, td").Each(func(_ int, cell *goquery.Selection) {
|
||||
text := normalizeCETText(cell.Text())
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if cell.Is("th") {
|
||||
label = normalizeCETLabel(text)
|
||||
return
|
||||
}
|
||||
|
||||
assignCETStudentField(info, label, text)
|
||||
label = ""
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func assignCETStudentField(info *CETStudentInfo, label, value string) {
|
||||
switch label {
|
||||
case "学号":
|
||||
info.StudentID = value
|
||||
case "姓名":
|
||||
info.Name = value
|
||||
case "性别":
|
||||
info.Gender = value
|
||||
case "身份证号":
|
||||
info.IDCard = value
|
||||
case "学制":
|
||||
info.Duration = value
|
||||
case "年级":
|
||||
info.Grade = value
|
||||
case "学院":
|
||||
info.College = value
|
||||
case "专业":
|
||||
info.Major = value
|
||||
}
|
||||
}
|
||||
|
||||
func parseCETScores(table *goquery.Selection) []CETScore {
|
||||
var scores []CETScore
|
||||
table.Find("tr").Each(func(_ int, row *goquery.Selection) {
|
||||
rowText := normalizeCETText(row.Text())
|
||||
if !strings.Contains(rowText, "英语四级") && !strings.Contains(rowText, "英语六级") {
|
||||
return
|
||||
}
|
||||
scores = append(scores, parseCETScoresFromText(rowText)...)
|
||||
})
|
||||
return deduplicateCETScores(scores)
|
||||
}
|
||||
|
||||
func parseCETScoresFromText(text string) []CETScore {
|
||||
text = normalizeCETText(text)
|
||||
matches := cetScoreRegex.FindAllStringSubmatch(text, -1)
|
||||
scores := make([]CETScore, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
if len(match) < 5 {
|
||||
continue
|
||||
}
|
||||
scores = append(scores, CETScore{
|
||||
Level: strings.TrimSpace(match[1]),
|
||||
ExamTime: strings.TrimSpace(match[2]),
|
||||
TicketNum: strings.TrimSpace(match[3]),
|
||||
Score: strings.TrimSpace(match[4]),
|
||||
})
|
||||
}
|
||||
return deduplicateCETScores(scores)
|
||||
}
|
||||
|
||||
func deduplicateCETScores(scores []CETScore) []CETScore {
|
||||
if len(scores) <= 1 {
|
||||
return scores
|
||||
}
|
||||
seen := make(map[string]struct{}, len(scores))
|
||||
result := make([]CETScore, 0, len(scores))
|
||||
for _, score := range scores {
|
||||
key := strings.Join([]string{score.Level, score.ExamTime, score.TicketNum, score.Score}, "|")
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, score)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeCETLabel(label string) string {
|
||||
label = normalizeCETText(label)
|
||||
label = strings.ReplaceAll(label, " ", "")
|
||||
label = strings.ReplaceAll(label, ":", "")
|
||||
label = strings.ReplaceAll(label, ":", "")
|
||||
return label
|
||||
}
|
||||
|
||||
func normalizeCETText(text string) string {
|
||||
text = strings.ReplaceAll(text, "\u00a0", " ")
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
||||
}
|
||||
155
parser/empty_classroom.go
Normal file
155
parser/empty_classroom.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"schedule_converter/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
// ParseEmptyClassroomHTML 解析空教室查询结果 HTML,返回空教室条目列表。
|
||||
func ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) {
|
||||
if html == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Clean nbsp entities
|
||||
cleaned := strings.ReplaceAll(html, " ", "")
|
||||
cleaned = strings.ReplaceAll(cleaned, "&", "&")
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(cleaned))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dayNames := [7]string{"星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"}
|
||||
periodLabels := [6]string{"1,2", "3,4", "5,6", "7,8", "9,10", "11,12"}
|
||||
|
||||
var classrooms []models.EmptyClassroomEntry
|
||||
|
||||
// Find the main data table with class="dataTable"
|
||||
doc.Find("table.dataTable").Each(func(_ int, table *goquery.Selection) {
|
||||
rows := table.Find("tr")
|
||||
if rows.Length() < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify this is the classroom table by checking for weekday headers
|
||||
hasWeekdayHeader := false
|
||||
rows.First().Find("th").Each(func(_ int, th *goquery.Selection) {
|
||||
text := strings.TrimSpace(th.Text())
|
||||
for _, day := range dayNames {
|
||||
if strings.Contains(text, day) {
|
||||
hasWeekdayHeader = true
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
if !hasWeekdayHeader {
|
||||
return
|
||||
}
|
||||
|
||||
// Build column mapping: column index -> (dayIndex, periodIndex)
|
||||
colMapping := make(map[int][2]int)
|
||||
|
||||
// Start from column 1 (skip first column which is classroom name)
|
||||
colIdx := 1
|
||||
for dayIdx := 0; dayIdx < 7; dayIdx++ {
|
||||
for periodIdx := 0; periodIdx < 6; periodIdx++ {
|
||||
colMapping[colIdx] = [2]int{dayIdx, periodIdx}
|
||||
colIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Process data rows (skip first 2 header rows)
|
||||
rows.Each(func(rowIdx int, row *goquery.Selection) {
|
||||
if rowIdx < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
cells := row.Find("td")
|
||||
if cells.Length() < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
// Get classroom name from first cell
|
||||
classroomName := strings.TrimSpace(cells.First().Text())
|
||||
// Filter out invalid classroom names
|
||||
if classroomName == "" || len(classroomName) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip non-classroom entries like "线上网络授课"
|
||||
if strings.Contains(classroomName, "线上") || strings.Contains(classroomName, "网络") {
|
||||
return
|
||||
}
|
||||
|
||||
// Collect empty slots for this classroom
|
||||
var slots []string
|
||||
weekdaySet := make(map[int]bool)
|
||||
var weekdays []string
|
||||
|
||||
// Check each time slot column
|
||||
for colIdx := 1; colIdx < cells.Length(); colIdx++ {
|
||||
cell := cells.Eq(colIdx)
|
||||
|
||||
// Get mapping for this column
|
||||
mapping, exists := colMapping[colIdx]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
dayIdx := mapping[0]
|
||||
periodIdx := mapping[1]
|
||||
|
||||
// Check if cell is empty (no kjs_icon div)
|
||||
isEmpty := true
|
||||
cell.Find("div.kjs_icon").Each(func(_ int, div *goquery.Selection) {
|
||||
isEmpty = false
|
||||
})
|
||||
|
||||
// Also check if cell has any content
|
||||
cellText := strings.TrimSpace(cell.Text())
|
||||
if cellText != "" {
|
||||
isEmpty = false
|
||||
}
|
||||
|
||||
if isEmpty {
|
||||
dayName := dayNames[dayIdx]
|
||||
periodText := periodLabels[periodIdx]
|
||||
slot := fmt.Sprintf("%s 第%s节", dayName, periodText)
|
||||
slots = append(slots, slot)
|
||||
|
||||
if !weekdaySet[dayIdx] {
|
||||
weekdaySet[dayIdx] = true
|
||||
weekdays = append(weekdays, dayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create entry if there are empty slots
|
||||
if len(slots) > 0 {
|
||||
weekStr := ""
|
||||
if weekStart != "" && weekEnd != "" {
|
||||
weekStr = fmt.Sprintf("%s-%s周", weekStart, weekEnd)
|
||||
}
|
||||
entry := models.EmptyClassroomEntry{
|
||||
Classroom: classroomName,
|
||||
Weekday: strings.Join(weekdays, ", "),
|
||||
Periods: strings.Join(slots, "\n"),
|
||||
Term: semester,
|
||||
Weeks: weekStr,
|
||||
}
|
||||
classrooms = append(classrooms, entry)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return classrooms, nil
|
||||
}
|
||||
|
||||
// TestParseEmptyClassroomHTML is a test helper function that exports the parser for testing
|
||||
func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) {
|
||||
return ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
|
||||
}
|
||||
70
parser/exam.go
Normal file
70
parser/exam.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"schedule_converter/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
// examTypeToCode 将考试类型映射为教务系统的请求码。
|
||||
func examTypeToCode(t models.ExamType) string {
|
||||
switch t {
|
||||
case models.ExamTypeMidterm:
|
||||
return "02"
|
||||
case models.ExamTypeMakeup:
|
||||
return "03"
|
||||
default:
|
||||
return "01"
|
||||
}
|
||||
}
|
||||
|
||||
// ExamTypeCode 返回考试类型对应的请求码(供 client 层构造请求使用)。
|
||||
func ExamTypeCode(t models.ExamType) string {
|
||||
return examTypeToCode(t)
|
||||
}
|
||||
|
||||
// ParseExamHTML 解析考试安排页面 HTML,返回考试条目列表。
|
||||
func ParseExamHTML(html string, examType models.ExamType) ([]models.ExamEntry, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var exams []models.ExamEntry
|
||||
|
||||
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
|
||||
rows := table.Find("tr")
|
||||
if rows.Length() < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
rows.Each(func(idx int, row *goquery.Selection) {
|
||||
if idx == 0 {
|
||||
return
|
||||
}
|
||||
cells := row.Find("td")
|
||||
if cells.Length() < 6 {
|
||||
return
|
||||
}
|
||||
|
||||
texts := getCellTexts(cells)
|
||||
|
||||
exam := models.ExamEntry{
|
||||
Course: safeGet(texts, 1),
|
||||
Date: safeGet(texts, 2),
|
||||
Time: safeGet(texts, 3),
|
||||
Week: safeGet(texts, 4),
|
||||
Weekday: safeGet(texts, 5),
|
||||
Type: examType,
|
||||
}
|
||||
|
||||
if exam.Course != "" && len(exam.Course) >= 2 {
|
||||
exams = append(exams, exam)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return exams, nil
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jwts/client"
|
||||
"jwts/config"
|
||||
"jwts/models"
|
||||
"schedule_converter/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
@@ -25,72 +18,6 @@ type weekRoomEntry struct {
|
||||
room string
|
||||
}
|
||||
|
||||
// 获取课表
|
||||
func FetchSchedule(httpClient *http.Client) ([]models.Course, error) {
|
||||
return FetchScheduleWithClient(httpClient, config.Semester)
|
||||
}
|
||||
|
||||
// FetchScheduleWithClient 使用指定的 HTTP 客户端和学期获取课表
|
||||
func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models.Course, error) {
|
||||
fmt.Println("[5] 获取课表数据...")
|
||||
|
||||
// POST请求获取课表
|
||||
data := fmt.Sprintf("xnxq=%s", semester)
|
||||
req, err := http.NewRequest("POST", config.BaseURL+"/kbcx/queryGrkb", strings.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf(" 共提取 %d 门课程\n", len(courses))
|
||||
|
||||
// 输出到JSON
|
||||
jsonData, err := os.Create("schedule_result.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer jsonData.Close()
|
||||
|
||||
encoder := json.NewEncoder(jsonData)
|
||||
encoder.SetIndent("", " ")
|
||||
err = encoder.Encode(courses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Println(" JSON结果已保存到: schedule_result.json")
|
||||
|
||||
// 打印课程信息
|
||||
for i, course := range courses {
|
||||
fmt.Printf(" 课程 %d: %s - %s\n", i+1, course.Name, course.Teacher)
|
||||
}
|
||||
|
||||
return courses, nil
|
||||
}
|
||||
|
||||
// 解析课表HTML
|
||||
func ParseScheduleHTML(html string) ([]models.Course, error) {
|
||||
// 创建一个带Reader的Document
|
||||
@@ -360,27 +287,6 @@ func buildCourseKey(name, teacher, room string) string {
|
||||
return strings.TrimSpace(name) + "|" + strings.TrimSpace(teacher) + "|" + strings.TrimSpace(room)
|
||||
}
|
||||
|
||||
// extractWeekInfo 提取一段文本中的完整周次片段
|
||||
// 例如:于战华[15]周,[2-14]周 -> before=于战华, weeks=[15]周,[2-14]周, after=
|
||||
func extractWeekInfo(text string) (beforeWeek, weeksPart, afterWeek string, ok bool) {
|
||||
indexes := weekTokenRegexp.FindAllStringIndex(text, -1)
|
||||
if len(indexes) == 0 {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
firstStart := indexes[0][0]
|
||||
beforeWeek = strings.TrimSpace(text[:firstStart])
|
||||
|
||||
parts := weekTokenRegexp.FindAllString(text, -1)
|
||||
weeksPart = strings.Join(parts, ",")
|
||||
|
||||
// 周次后(含中间)非周次内容作为教室候选
|
||||
tail := strings.TrimSpace(text[firstStart:])
|
||||
afterWeek = strings.TrimSpace(weekTokenRegexp.ReplaceAllString(tail, ""))
|
||||
afterWeek = strings.Trim(afterWeek, ",, ")
|
||||
return beforeWeek, weeksPart, afterWeek, true
|
||||
}
|
||||
|
||||
// extractWeekEntries 将一段文本拆成多个“周次-地点”片段
|
||||
// 例如:李鹏程[2-8]周N楼-335,[9]周N楼-331
|
||||
// 会拆成:
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"jwts/models"
|
||||
"schedule_converter/models"
|
||||
)
|
||||
|
||||
func TestParseScheduleHTML_FromTestHTML_MergedWeeks(t *testing.T) {
|
||||
|
||||
203
parser/score.go
Normal file
203
parser/score.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"schedule_converter/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
// ParseGradesHTML 解析成绩页面 HTML,返回成绩条目列表。
|
||||
func ParseGradesHTML(html string) ([]models.GradeEntry, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var grades []models.GradeEntry
|
||||
|
||||
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
|
||||
rows := table.Find("tr")
|
||||
if rows.Length() < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
rows.Each(func(idx int, row *goquery.Selection) {
|
||||
if idx == 0 {
|
||||
return
|
||||
}
|
||||
cells := row.Find("td")
|
||||
if cells.Length() < 8 {
|
||||
return
|
||||
}
|
||||
|
||||
texts := getCellTexts(cells)
|
||||
if len(texts) < 9 {
|
||||
return
|
||||
}
|
||||
|
||||
isMidterm := len(texts) == 10
|
||||
|
||||
entry := parseGradeRowTexts(texts, detectRetake(row))
|
||||
|
||||
if isMidterm && entry.Score == "" {
|
||||
entry.Score = safeGet(texts, 8)
|
||||
if len(texts) > 9 {
|
||||
entry.GradePoint = safeGet(texts, 9)
|
||||
}
|
||||
}
|
||||
|
||||
if entry.Name != "" && len(entry.Name) >= 2 {
|
||||
if entry.Score == "" {
|
||||
entry.Score = "-"
|
||||
}
|
||||
grades = append(grades, entry)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return grades, nil
|
||||
}
|
||||
|
||||
// ParseGPAHTML 解析学分绩页面 HTML,返回 GPA 概要与成绩条目列表。
|
||||
func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
summary := &models.GPASummary{}
|
||||
|
||||
doc.Find("span").Each(func(_ int, span *goquery.Selection) {
|
||||
id, _ := span.Attr("id")
|
||||
text := strings.TrimSpace(span.Text())
|
||||
switch id {
|
||||
case "pjxfj":
|
||||
summary.AverageGPA = text
|
||||
case "zrs":
|
||||
summary.Rank = text
|
||||
case "kskxfj":
|
||||
summary.ExamTotalGPA = text
|
||||
case "kskxfs":
|
||||
summary.ExamTotalCredits = text
|
||||
case "kckxfj":
|
||||
summary.FailCredits = text
|
||||
case "xqs":
|
||||
summary.Semesters = text
|
||||
}
|
||||
})
|
||||
|
||||
var grades []models.GradeEntry
|
||||
|
||||
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
|
||||
rows := table.Find("tr")
|
||||
if rows.Length() < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
rows.Each(func(idx int, row *goquery.Selection) {
|
||||
if idx == 0 {
|
||||
return
|
||||
}
|
||||
cells := row.Find("td")
|
||||
if cells.Length() < 10 {
|
||||
return
|
||||
}
|
||||
|
||||
texts := getCellTexts(cells)
|
||||
|
||||
entry := parseGradeRowTexts(texts, detectRetake(row))
|
||||
|
||||
if entry.Name != "" && len(entry.Name) >= 2 {
|
||||
grades = append(grades, entry)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return summary, grades, nil
|
||||
}
|
||||
|
||||
func parseGradeRowTexts(texts []string, isRetake bool) models.GradeEntry {
|
||||
entry := models.GradeEntry{IsRetake: isRetake}
|
||||
|
||||
// 详细成绩/GPA 页面格式:
|
||||
// 序号、学期、学院、课程代码、课程名称、课程性质、课程类别、学分、是否考试课、是否学位课、成绩标记、成绩、最终成绩、绩点1、绩点、录入时间、备注。
|
||||
if len(texts) >= 15 && looksLikeDetailedGradeRow(texts) {
|
||||
entry.Term = safeGet(texts, 1)
|
||||
entry.Code = safeGet(texts, 3)
|
||||
entry.Name = safeGet(texts, 4)
|
||||
entry.Nature = safeGet(texts, 5)
|
||||
entry.Category = safeGet(texts, 6)
|
||||
entry.Credit = safeGet(texts, 7)
|
||||
entry.IsExam = safeGet(texts, 8)
|
||||
entry.Score = firstNonEmpty(safeGet(texts, 12), safeGet(texts, 11))
|
||||
entry.GradePoint = firstNonEmpty(safeGet(texts, 14), safeGet(texts, 13))
|
||||
return entry
|
||||
}
|
||||
|
||||
// 常规成绩页面格式:学年、学期、课程代码、课程名称、课程性质、课程类别、学分、成绩、绩点、...
|
||||
if len(texts) >= 10 {
|
||||
entry.Term = safeGet(texts, 1)
|
||||
entry.Code = safeGet(texts, 2)
|
||||
entry.Name = safeGet(texts, 3)
|
||||
entry.Nature = safeGet(texts, 4)
|
||||
entry.Category = safeGet(texts, 5)
|
||||
entry.Credit = safeGet(texts, 6)
|
||||
entry.Score = safeGet(texts, 7)
|
||||
entry.GradePoint = safeGet(texts, 8)
|
||||
return entry
|
||||
}
|
||||
|
||||
entry.Term = safeGet(texts, 1)
|
||||
entry.Code = safeGet(texts, 3)
|
||||
entry.Name = safeGet(texts, 4)
|
||||
entry.Credit = safeGet(texts, 7)
|
||||
entry.Score = safeGet(texts, 8)
|
||||
if len(texts) > 9 {
|
||||
entry.GradePoint = safeGet(texts, 9)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func looksLikeDetailedGradeRow(texts []string) bool {
|
||||
return safeGet(texts, 3) != "" && safeGet(texts, 4) != "" && safeGet(texts, 7) != "" && (safeGet(texts, 8) == "是" || safeGet(texts, 8) == "否")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detectRetake 检测成绩行是否包含补考标记。
|
||||
// 教务系统在成绩单元格内以 <font color="#0066ff">补考</font> 标记补考,
|
||||
// 也会在整行文本中出现"补考"字样。
|
||||
func detectRetake(row *goquery.Selection) bool {
|
||||
// 方式1:查找包含"补考"文字的 <font> 标签
|
||||
if row.Find("font:contains('补考')").Length() > 0 {
|
||||
return true
|
||||
}
|
||||
// 方式2:整行纯文本包含"补考"
|
||||
rowText := strings.TrimSpace(row.Text())
|
||||
return strings.Contains(rowText, "补考")
|
||||
}
|
||||
|
||||
func getCellTexts(cells *goquery.Selection) []string {
|
||||
var texts []string
|
||||
cells.Each(func(_ int, cell *goquery.Selection) {
|
||||
text := strings.TrimSpace(cell.Text())
|
||||
texts = append(texts, text)
|
||||
})
|
||||
return texts
|
||||
}
|
||||
|
||||
func safeGet(arr []string, idx int) string {
|
||||
if idx < len(arr) {
|
||||
return arr[idx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
34
pkg/errors/errors.go
Normal file
34
pkg/errors/errors.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ErrLoginFailed = errors.New("login failed")
|
||||
|
||||
// AppError 携带操作名(Op)与中文提示(Msg)的错误类型,用于统一日志与错误上报。
|
||||
type AppError struct {
|
||||
Op string
|
||||
Err error
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *AppError) 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 *AppError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func New(op string, err error, msg string) error {
|
||||
return &AppError{
|
||||
Op: op,
|
||||
Err: err,
|
||||
Msg: msg,
|
||||
}
|
||||
}
|
||||
33
pkg/logger/logger.go
Normal file
33
pkg/logger/logger.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
var currentLevel = slog.LevelInfo
|
||||
|
||||
func init() {
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: currentLevel,
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
slog.SetDefault(logger)
|
||||
}
|
||||
|
||||
func SetLevel(level slog.Level) {
|
||||
currentLevel = level
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: currentLevel,
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
slog.SetDefault(logger)
|
||||
}
|
||||
|
||||
func SetTextHandler() {
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: currentLevel,
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||||
slog.SetDefault(logger)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package runner;
|
||||
|
||||
option go_package = "jwts/proto/runner;runner";
|
||||
option go_package = "schedule_converter/proto/runner;runner";
|
||||
|
||||
// ============================================================
|
||||
// 服务定义
|
||||
@@ -81,6 +81,8 @@ enum TaskType {
|
||||
TASK_TYPE_GET_GRADES = 3; // 获取成绩
|
||||
TASK_TYPE_GET_EXAMS = 4; // 获取考试安排
|
||||
TASK_TYPE_GET_USER_INFO = 5; // 获取用户信息
|
||||
TASK_TYPE_GET_EMPTY_CLASSROOM = 6; // 获取空教室
|
||||
TASK_TYPE_GET_CET = 7; // 获取四六级成绩
|
||||
}
|
||||
|
||||
// TaskStatus 任务状态枚举
|
||||
@@ -178,20 +180,36 @@ message ScheduleTime {
|
||||
repeated int32 weeks = 3; // 周次,如 [1, 2, 3, 4, 5]
|
||||
}
|
||||
|
||||
// GpaSummary 学分绩汇总信息
|
||||
message GpaSummary {
|
||||
string average_gpa = 1; // 平均学分绩
|
||||
string rank = 2; // 专业排名
|
||||
string exam_total_gpa = 3; // 考试课学分绩
|
||||
string exam_total_credits = 4; // 考试课学分数
|
||||
string fail_credits = 5; // 考查课不及格学分
|
||||
string semesters = 6; // 计算学期数
|
||||
}
|
||||
|
||||
// GradesResultData 成绩结果数据
|
||||
message GradesResultData {
|
||||
string semester = 1; // 学期
|
||||
string student_id = 2; // 学号
|
||||
repeated Grade grades = 3; // 成绩列表
|
||||
GpaSummary gpa_summary = 4; // 学分绩汇总 (可选)
|
||||
}
|
||||
|
||||
// Grade 成绩信息
|
||||
message Grade {
|
||||
string course_code = 1; // 课程代码
|
||||
string course_name = 2; // 课程名称
|
||||
float credit = 3; // 学分
|
||||
string grade = 4; // 成绩 (可能是等级或分数)
|
||||
float grade_point = 5; // 绩点
|
||||
string term = 1; // 学期
|
||||
string course_code = 2; // 课程代码
|
||||
string course_name = 3; // 课程名称
|
||||
string nature = 4; // 课程性质
|
||||
string category = 5; // 课程类别
|
||||
string credit = 6; // 学分
|
||||
string score = 7; // 成绩 (可能是等级或分数)
|
||||
string grade_point = 8; // 绩点
|
||||
string is_exam = 9; // 是否考试课
|
||||
bool is_retake = 10; // 是否为补考
|
||||
}
|
||||
|
||||
// ExamsResultData 考试安排结果数据
|
||||
@@ -203,13 +221,12 @@ message ExamsResultData {
|
||||
|
||||
// Exam 考试信息
|
||||
message Exam {
|
||||
string course_code = 1; // 课程代码
|
||||
string course_name = 2; // 课程名称
|
||||
string exam_type = 3; // 考试类型 (期末/期中/补考)
|
||||
string date = 4; // 考试日期 (YYYY-MM-DD)
|
||||
string time = 5; // 考试时间 (HH:MM-HH:MM)
|
||||
string room = 6; // 考场
|
||||
string seat = 7; // 座位号
|
||||
string course_name = 1; // 课程名称
|
||||
string exam_type = 2; // 考试类型 (期末/期中/补考)
|
||||
string date = 3; // 考试日期
|
||||
string time = 4; // 考试时间
|
||||
string week = 5; // 考试周次
|
||||
string weekday = 6; // 星期几
|
||||
}
|
||||
|
||||
// UserInfoResultData 用户信息结果数据
|
||||
@@ -222,3 +239,62 @@ message UserInfoResultData {
|
||||
string class_name = 6; // 班级
|
||||
string grade = 7; // 年级
|
||||
}
|
||||
|
||||
// GetEmptyClassroomPayload 获取空教室任务载荷
|
||||
message GetEmptyClassroomPayload {
|
||||
string username = 1; // 学号
|
||||
string password = 2; // 密码
|
||||
string semester = 3; // 学期
|
||||
string week_start = 4; // 起始周次
|
||||
string week_end = 5; // 结束周次
|
||||
string campus_code = 6; // 校区代码
|
||||
string building_code = 7; // 楼号代码(可选)
|
||||
}
|
||||
|
||||
// EmptyClassroomResultData 空教室结果数据
|
||||
message EmptyClassroomResultData {
|
||||
string semester = 1; // 学期
|
||||
string student_id = 2; // 学号
|
||||
repeated EmptyClassroomEntry classrooms = 3; // 空教室列表
|
||||
}
|
||||
|
||||
// EmptyClassroomEntry 空教室条目
|
||||
message EmptyClassroomEntry {
|
||||
string classroom = 1; // 教室名称
|
||||
string weekday = 2; // 星期几
|
||||
string periods = 3; // 可用节次
|
||||
string term = 4; // 学期描述
|
||||
string weeks = 5; // 周次范围
|
||||
}
|
||||
|
||||
// GetCetPayload 获取四六级成绩任务载荷
|
||||
message GetCetPayload {
|
||||
string username = 1; // 学号
|
||||
string password = 2; // 密码
|
||||
}
|
||||
|
||||
// CetScore 四六级成绩条目
|
||||
message CetScore {
|
||||
string level = 1; // 等级(英语四级/英语六级)
|
||||
string exam_time = 2; // 考试时间,如 2025年06月
|
||||
string ticket_num = 3; // 准考证号
|
||||
string score = 4; // 成绩
|
||||
}
|
||||
|
||||
// CetStudentInfo 四六级查询页面的学生信息
|
||||
message CetStudentInfo {
|
||||
string student_id = 1; // 学号
|
||||
string name = 2; // 姓名
|
||||
string gender = 3; // 性别
|
||||
string id_card = 4; // 身份证号
|
||||
string duration = 5; // 学制
|
||||
string grade = 6; // 年级
|
||||
string college = 7; // 学院
|
||||
string major = 8; // 专业
|
||||
}
|
||||
|
||||
// CetResultData 四六级成绩结果数据
|
||||
message CetResultData {
|
||||
CetStudentInfo student_info = 1; // 学生信息
|
||||
repeated CetScore scores = 2; // 成绩列表
|
||||
}
|
||||
|
||||
@@ -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 v6.32.0
|
||||
// source: proto/runner/runner.proto
|
||||
|
||||
package runner
|
||||
|
||||
66
service/cet.go
Normal file
66
service/cet.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/parser"
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
)
|
||||
|
||||
const cetQueryPath = "/sljcjcx/sljcjcxInit"
|
||||
|
||||
// GetCET 获取四六级成绩:登录 → 请求四六级查询结果页 → 解析 → 转换为 pb 结果。
|
||||
func (s *Service) GetCET(ctx context.Context, req *pb.GetCetPayload) (*pb.CetResultData, error) {
|
||||
log := slog.With("username", req.Username)
|
||||
|
||||
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
html, err := client.FetchHTML(ctx, httpClient, "GET", cetQueryPath, nil, "", "")
|
||||
if err != nil {
|
||||
return nil, apperr.New("fetch_cet", err, "获取四六级成绩页面失败")
|
||||
}
|
||||
|
||||
cetResult, err := parser.ParseCETHTML(html)
|
||||
if err != nil {
|
||||
return nil, apperr.New("parse_cet", err, "解析四六级成绩失败")
|
||||
}
|
||||
|
||||
log.Info("四六级成绩获取成功", "score_count", len(cetResult.Scores))
|
||||
return &pb.CetResultData{
|
||||
StudentInfo: convertCETStudentInfo(cetResult.StudentInfo),
|
||||
Scores: convertCETScores(cetResult.Scores),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertCETStudentInfo(info parser.CETStudentInfo) *pb.CetStudentInfo {
|
||||
return &pb.CetStudentInfo{
|
||||
StudentId: info.StudentID,
|
||||
Name: info.Name,
|
||||
Gender: info.Gender,
|
||||
IdCard: info.IDCard,
|
||||
Duration: info.Duration,
|
||||
Grade: info.Grade,
|
||||
College: info.College,
|
||||
Major: info.Major,
|
||||
}
|
||||
}
|
||||
|
||||
func convertCETScores(scores []parser.CETScore) []*pb.CetScore {
|
||||
result := make([]*pb.CetScore, 0, len(scores))
|
||||
for _, score := range scores {
|
||||
result = append(result, &pb.CetScore{
|
||||
Level: score.Level,
|
||||
ExamTime: score.ExamTime,
|
||||
TicketNum: score.TicketNum,
|
||||
Score: score.Score,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
77
service/classroom.go
Normal file
77
service/classroom.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/config"
|
||||
"schedule_converter/models"
|
||||
"schedule_converter/parser"
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
)
|
||||
|
||||
// GetEmptyClassrooms 获取空教室:登录 → GET 建立会话 → POST 查询 → 解析 → 转换为 pb 结果。
|
||||
func (s *Service) GetEmptyClassrooms(ctx context.Context, req *pb.GetEmptyClassroomPayload) (*pb.EmptyClassroomResultData, error) {
|
||||
log := slog.With("username", req.Username, "semester", req.Semester)
|
||||
|
||||
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kjsURL := config.Get().BaseURL + "/kjscx/queryKjs"
|
||||
|
||||
// 先 GET 建立会话(沿用原有行为:直接用 client.Get,仅取 cookie,丢弃 body)。
|
||||
sessionReq, err := http.NewRequestWithContext(ctx, "GET", kjsURL, nil)
|
||||
if err != nil {
|
||||
return nil, apperr.New("session_classroom", err, "建立空教室会话失败")
|
||||
}
|
||||
sessionReq.Header = client.GetHeaders()
|
||||
sessionResp, err := httpClient.Do(sessionReq)
|
||||
if err != nil {
|
||||
return nil, apperr.New("session_classroom", err, "访问空教室页面失败")
|
||||
}
|
||||
sessionResp.Body.Close()
|
||||
|
||||
// POST 查询参数。
|
||||
data := fmt.Sprintf("pageXnxq=%s&pageZc1=%s&pageZc2=%s&pageXiaoqu=%s",
|
||||
req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode)
|
||||
html, err := client.FetchHTML(ctx, httpClient, "POST", "/kjscx/queryKjs", strings.NewReader(data), "application/x-www-form-urlencoded", "/kjscx/queryKjs")
|
||||
if err != nil {
|
||||
return nil, apperr.New("fetch_empty_classrooms", err, "查询空教室失败")
|
||||
}
|
||||
|
||||
entries, err := parser.ParseEmptyClassroomHTML(html, req.Semester, req.WeekStart, req.WeekEnd)
|
||||
if err != nil {
|
||||
return nil, apperr.New("parse_empty_classrooms", err, "解析空教室失败")
|
||||
}
|
||||
|
||||
log.Info("空教室获取成功", "classroom_count", len(entries))
|
||||
|
||||
return &pb.EmptyClassroomResultData{
|
||||
StudentId: req.Username,
|
||||
Semester: req.Semester,
|
||||
Classrooms: convertEmptyClassrooms(entries),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// convertEmptyClassrooms 将内部空教室模型转换为 pb.EmptyClassroomEntry 列表。
|
||||
func convertEmptyClassrooms(entries []models.EmptyClassroomEntry) []*pb.EmptyClassroomEntry {
|
||||
result := make([]*pb.EmptyClassroomEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
result = append(result, &pb.EmptyClassroomEntry{
|
||||
Classroom: e.Classroom,
|
||||
Weekday: e.Weekday,
|
||||
Periods: e.Periods,
|
||||
Term: e.Term,
|
||||
Weeks: e.Weeks,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
79
service/exams.go
Normal file
79
service/exams.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/models"
|
||||
"schedule_converter/parser"
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
)
|
||||
|
||||
var allExamTypes = []models.ExamType{models.ExamTypeFinal, models.ExamTypeMidterm, models.ExamTypeMakeup}
|
||||
|
||||
// GetExams 获取考试安排:登录 → 遍历各考试类型抓取并解析 → 合并 → 转换为 pb 结果。
|
||||
func (s *Service) GetExams(ctx context.Context, req *pb.GetExamsPayload) (*pb.ExamsResultData, error) {
|
||||
log := slog.With("username", req.Username, "semester", req.Semester)
|
||||
|
||||
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allExams []models.ExamEntry
|
||||
var lastErr error
|
||||
for _, et := range allExamTypes {
|
||||
data := fmt.Sprintf("xnxq=&kssjd=%s", parser.ExamTypeCode(et))
|
||||
html, ferr := client.FetchHTML(ctx, httpClient, "POST", "/kscx/queryKsForXs", strings.NewReader(data), "application/x-www-form-urlencoded", "")
|
||||
if ferr != nil {
|
||||
log.Warn("获取考试数据失败", "exam_type", string(et), "error", ferr)
|
||||
lastErr = ferr
|
||||
continue
|
||||
}
|
||||
exams, perr := parser.ParseExamHTML(html, et)
|
||||
if perr != nil {
|
||||
log.Warn("解析考试数据失败", "exam_type", string(et), "error", perr)
|
||||
lastErr = perr
|
||||
continue
|
||||
}
|
||||
allExams = append(allExams, exams...)
|
||||
}
|
||||
|
||||
if len(allExams) == 0 && lastErr != nil {
|
||||
return nil, apperr.New("fetch_exams", lastErr, "所有考试类型获取均失败")
|
||||
}
|
||||
|
||||
return &pb.ExamsResultData{
|
||||
StudentId: req.Username,
|
||||
Semester: req.Semester,
|
||||
Exams: convertExams(allExams),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// convertExams 将内部考试模型转换为 pb.Exam 列表。
|
||||
func convertExams(exams []models.ExamEntry) []*pb.Exam {
|
||||
result := make([]*pb.Exam, 0, len(exams))
|
||||
for _, e := range exams {
|
||||
examTypeStr := "期末"
|
||||
switch e.Type {
|
||||
case models.ExamTypeMidterm:
|
||||
examTypeStr = "期中"
|
||||
case models.ExamTypeMakeup:
|
||||
examTypeStr = "补考"
|
||||
}
|
||||
result = append(result, &pb.Exam{
|
||||
CourseName: e.Course,
|
||||
ExamType: examTypeStr,
|
||||
Date: e.Date,
|
||||
Time: e.Time,
|
||||
Week: e.Week,
|
||||
Weekday: e.Weekday,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
85
service/grades.go
Normal file
85
service/grades.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/models"
|
||||
"schedule_converter/parser"
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
)
|
||||
|
||||
// GetGrades 获取成绩:登录 → 优先 GPA 页面 → 回退到成绩页面 → 解析 → 转换为 pb 结果。
|
||||
func (s *Service) GetGrades(ctx context.Context, req *pb.GetGradesPayload) (*pb.GradesResultData, error) {
|
||||
log := slog.With("username", req.Username, "semester", req.Semester)
|
||||
|
||||
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var gpaSummary *pb.GpaSummary
|
||||
|
||||
gpaHTML, err := client.FetchHTML(ctx, httpClient, "GET", "/xfj/queryListXfj", nil, "", "")
|
||||
if err != nil {
|
||||
log.Warn("获取GPA页面失败,尝试成绩页面", "error", err)
|
||||
gradesHTML, ferr := client.FetchHTML(ctx, httpClient, "GET", "/cjcx/queryQmcj", nil, "", "")
|
||||
if ferr != nil {
|
||||
return nil, apperr.New("fetch_grades", ferr, "获取成绩失败")
|
||||
}
|
||||
grades, perr := parser.ParseGradesHTML(gradesHTML)
|
||||
if perr != nil {
|
||||
return nil, apperr.New("parse_grades", perr, "解析成绩失败")
|
||||
}
|
||||
return &pb.GradesResultData{
|
||||
StudentId: req.Username,
|
||||
Semester: req.Semester,
|
||||
Grades: convertGrades(grades),
|
||||
}, nil
|
||||
}
|
||||
|
||||
summary, grades, err := parser.ParseGPAHTML(gpaHTML)
|
||||
if err != nil {
|
||||
return nil, apperr.New("parse_gpa", err, "解析学分绩失败")
|
||||
}
|
||||
if summary != nil {
|
||||
gpaSummary = &pb.GpaSummary{
|
||||
AverageGpa: summary.AverageGPA,
|
||||
Rank: summary.Rank,
|
||||
ExamTotalGpa: summary.ExamTotalGPA,
|
||||
ExamTotalCredits: summary.ExamTotalCredits,
|
||||
FailCredits: summary.FailCredits,
|
||||
Semesters: summary.Semesters,
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.GradesResultData{
|
||||
StudentId: req.Username,
|
||||
Semester: req.Semester,
|
||||
Grades: convertGrades(grades),
|
||||
GpaSummary: gpaSummary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// convertGrades 将内部成绩模型转换为 pb.Grade 列表。
|
||||
func convertGrades(grades []models.GradeEntry) []*pb.Grade {
|
||||
result := make([]*pb.Grade, 0, len(grades))
|
||||
for _, g := range grades {
|
||||
result = append(result, &pb.Grade{
|
||||
Term: g.Term,
|
||||
CourseCode: g.Code,
|
||||
CourseName: g.Name,
|
||||
Nature: g.Nature,
|
||||
Category: g.Category,
|
||||
Credit: g.Credit,
|
||||
Score: g.Score,
|
||||
GradePoint: g.GradePoint,
|
||||
IsExam: g.IsExam,
|
||||
IsRetake: g.IsRetake,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
11
service/login.go
Normal file
11
service/login.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Login 仅执行登录流程,返回已认证的 HTTP 客户端(调用方可继续用它抓取数据)。
|
||||
func (s *Service) Login(ctx context.Context, username, password, twfid string) (*http.Client, error) {
|
||||
return s.newAuthenticatedClient(ctx, username, password, twfid)
|
||||
}
|
||||
75
service/schedule.go
Normal file
75
service/schedule.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/config"
|
||||
"schedule_converter/models"
|
||||
"schedule_converter/parser"
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
|
||||
pb "schedule_converter/proto/runner"
|
||||
)
|
||||
|
||||
// GetSchedule 获取课表:登录 → POST 查询 → 解析 → 转换为 pb 结果。
|
||||
func (s *Service) GetSchedule(ctx context.Context, req *pb.GetSchedulePayload) (*pb.ScheduleResultData, error) {
|
||||
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
semester := req.Semester
|
||||
if semester == "" {
|
||||
semester = config.Get().Semester
|
||||
}
|
||||
|
||||
data := fmt.Sprintf("xnxq=%s", semester)
|
||||
html, err := client.FetchHTML(ctx, httpClient, "POST", "/kbcx/queryGrkb", strings.NewReader(data), "application/x-www-form-urlencoded", "")
|
||||
if err != nil {
|
||||
return nil, apperr.New("fetch_schedule", err, "获取课表失败")
|
||||
}
|
||||
|
||||
courses, err := parser.ParseScheduleHTML(html)
|
||||
if err != nil {
|
||||
return nil, apperr.New("parse_schedule", err, "解析课表失败")
|
||||
}
|
||||
|
||||
return &pb.ScheduleResultData{
|
||||
StudentId: req.Username,
|
||||
Semester: semester,
|
||||
Courses: convertCourses(courses),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// convertCourses 将内部课程模型转换为 pb.Course 列表。
|
||||
func convertCourses(courses []models.Course) []*pb.Course {
|
||||
result := make([]*pb.Course, len(courses))
|
||||
for i, c := range courses {
|
||||
schedule := make([]*pb.ScheduleTime, len(c.Schedule))
|
||||
for j, sc := range c.Schedule {
|
||||
schedule[j] = &pb.ScheduleTime{
|
||||
Day: sc.Day,
|
||||
Sections: toInt32Slice(sc.Section),
|
||||
Weeks: toInt32Slice(sc.Weeks),
|
||||
}
|
||||
}
|
||||
result[i] = &pb.Course{
|
||||
Name: c.Name,
|
||||
Teacher: c.Teacher,
|
||||
Room: c.Room,
|
||||
Schedule: schedule,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toInt32Slice(arr []int) []int32 {
|
||||
result := make([]int32, len(arr))
|
||||
for i, v := range arr {
|
||||
result[i] = int32(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
95
service/service.go
Normal file
95
service/service.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"schedule_converter/auth"
|
||||
"schedule_converter/captcha"
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/config"
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLoginRetries = 3
|
||||
loginRetryInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
// Service 封装教务系统的登录与数据抓取编排逻辑。
|
||||
type Service struct{}
|
||||
|
||||
// NewService 创建 service。
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// newAuthenticatedClient 创建带 cookie jar 的 HTTP 客户端,设置 TWFID cookie 并完成登录。
|
||||
func (s *Service) newAuthenticatedClient(ctx context.Context, username, password, twfid string) (*http.Client, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, apperr.New("create_jar", err, "创建 cookie jar 失败")
|
||||
}
|
||||
httpClient := &http.Client{Jar: jar}
|
||||
|
||||
if twfid == "" {
|
||||
twfid = config.Get().TWFID
|
||||
}
|
||||
s.setTWFIDCookie(httpClient, config.Get().BaseURL, twfid)
|
||||
|
||||
if err := s.login(ctx, httpClient, username, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return httpClient, nil
|
||||
}
|
||||
|
||||
func (s *Service) setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
slog.Warn("解析 baseURL 失败,无法设置 TWFID cookie", "base_url", baseURL, "error", err)
|
||||
return
|
||||
}
|
||||
httpClient.Jar.SetCookies(u, []*http.Cookie{
|
||||
{Name: "TWFID", Value: twfid},
|
||||
})
|
||||
}
|
||||
|
||||
// login 执行登录流程,验证码固定使用占位值 "...."。
|
||||
func (s *Service) login(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 失败")
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxLoginRetries; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
log.Info("尝试登录", "attempt", attempt, "max_retries", maxLoginRetries)
|
||||
|
||||
captchaCode := captcha.GetCaptchaCode()
|
||||
success, err := auth.LoginWithClient(ctx, httpClient, captchaCode, username, password)
|
||||
if err != nil {
|
||||
log.Warn("登录请求失败", "error", err, "attempt", attempt)
|
||||
time.Sleep(loginRetryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
if success {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warn("登录验证失败", "attempt", attempt)
|
||||
time.Sleep(loginRetryInterval)
|
||||
}
|
||||
|
||||
return apperr.ErrLoginFailed
|
||||
}
|
||||
Reference in New Issue
Block a user