├── .github └── workflows │ └── main.yml ├── .gitignore ├── .goreleaser.yaml ├── AgentDockerfile ├── Makefile ├── Readme.md ├── ServerDockerfile ├── cmd ├── lan │ └── main.go └── wan │ └── main.go ├── conf └── config.toml ├── go.mod ├── go.sum └── internal ├── cache ├── DefaultCache.go └── Index.go ├── constant ├── Cache.go ├── Config.go ├── Default.go ├── Dingtalk.go └── Xxqg.go ├── controller ├── lan │ ├── Index.go │ └── UserController.go └── wan │ └── Index.go ├── domain ├── Communication.go └── User.go ├── initial ├── Index.go └── QuestionBank.go ├── job ├── lan │ ├── AutoKeepAliveJob.go │ ├── AutoStudyJob.go │ ├── CheckCompanyIp.go │ ├── CommunicationJob.go │ └── ResetScoreJob.go └── wan │ └── AccessTokenJob.go ├── model ├── Job.go └── User.go ├── service ├── DB.go ├── JobService.go ├── QuestionBankService.go ├── StatisticsService.go └── UserService.go ├── study ├── Answer.go ├── Article.go ├── BasicInfo.go ├── Video.go ├── XxqgService.go ├── core.go └── study.go ├── update ├── doc.go ├── index.go ├── other.go └── win.go └── util ├── Crypt.go ├── File.go ├── QuestionBank.go └── client.go /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - run: git fetch --force --tags 20 | - uses: actions/setup-go@v3 21 | with: 22 | go-version: '>=1.19.3' 23 | cache: true 24 | - uses: docker/login-action@v2 25 | with: 26 | username: ${{ secrets.DOCKER_USER }} 27 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 28 | - uses: goreleaser/goreleaser-action@v2 29 | with: 30 | distribution: goreleaser 31 | version: latest 32 | args: release --rm-dist 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /conf/ 2 | .idea/ 3 | target/ 4 | logs/ 5 | tools/ 6 | dist/ 7 | Makefile -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | # you may remove this if you don't need go generate 8 | # - go generate ./... 9 | builds: 10 | - id: "xxqg-agent" 11 | main: ./cmd/lan/main.go 12 | binary: xxqg-agent 13 | ldflags: 14 | - -s -w -X main.VERSION=v{{.Version}} 15 | env: 16 | - CGO_ENABLED=0 17 | goos: 18 | - windows 19 | - darwin 20 | - linux 21 | goarch: 22 | - amd64 23 | - arm64 24 | 25 | - id: "xxqg-server" 26 | main: ./cmd/wan/main.go 27 | binary: xxqg-server 28 | env: 29 | - CGO_ENABLED=0 30 | goos: 31 | - linux 32 | - windows 33 | - darwin 34 | goarch: 35 | - amd64 36 | - arm64 37 | 38 | archives: 39 | - id: "xxqg-agent" 40 | builds: 41 | - "xxqg-agent" 42 | format: binary 43 | replacements: 44 | darwin: Darwin 45 | linux: Linux 46 | windows: Windows 47 | 386: i386 48 | amd64: x86_64 49 | - id: "xxqg-server" 50 | builds: 51 | - "xxqg-server" 52 | format: binary 53 | replacements: 54 | darwin: Darwin 55 | linux: Linux 56 | windows: Windows 57 | 386: i386 58 | amd64: x86_64 59 | checksum: 60 | name_template: 'checksums.txt' 61 | snapshot: 62 | name_template: "{{ incpatch .Version }}-next" 63 | changelog: 64 | sort: asc 65 | filters: 66 | exclude: 67 | - '^docs:' 68 | - '^test:' 69 | 70 | # modelines, feel free to remove those if you don't want/use them: 71 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 72 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 73 | 74 | dockers: 75 | - 76 | id: server 77 | ids: 78 | - "xxqg-server" 79 | goos: linux 80 | goarch: amd64 81 | image_templates: 82 | - "yockii/xxqg_server:latest" 83 | - "yockii/xxqg_server:{{ .Tag }}" 84 | skip_push: false 85 | dockerfile: 'ServerDockerfile' 86 | extra_files: 87 | - conf/config.toml 88 | - 89 | id: server_arm64 90 | ids: 91 | - "xxqg-server" 92 | goos: linux 93 | goarch: arm64 94 | image_templates: 95 | - "yockii/xxqg_server:{{ .Tag }}_arm64" 96 | skip_push: false 97 | dockerfile: 'ServerDockerfile' 98 | extra_files: 99 | - conf/config.toml 100 | - 101 | id: agent 102 | ids: 103 | - "xxqg-agent" 104 | goos: linux 105 | goarch: amd64 106 | image_templates: 107 | - "yockii/xxqg_agent:latest" 108 | - "yockii/xxqg_agent:{{ .Tag }}" 109 | skip_push: false 110 | dockerfile: 'AgentDockerfile' 111 | extra_files: 112 | - conf/config.toml 113 | - 114 | id: agent_arm64 115 | ids: 116 | - "xxqg-agent" 117 | goos: linux 118 | goarch: arm64 119 | image_templates: 120 | - "yockii/xxqg_agent:{{ .Tag }}_arm64" 121 | skip_push: false 122 | dockerfile: 'AgentDockerfile' 123 | extra_files: 124 | - conf/config.toml -------------------------------------------------------------------------------- /AgentDockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/playwright:v1.29.0-alpha-nov-22-2022-jammy 2 | ENTRYPOINT ["/xxqg-agent"] 3 | COPY xxqg-agent / 4 | COPY conf/config.toml /conf/config.toml -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #srcDir=$(shell pwd) 2 | 3 | all: clean deps build compress 4 | 5 | wan: clean deps build-wan compress 6 | lan: clean deps build-lan compress 7 | 8 | clean: 9 | -rm -rf $(dir $(abspath $(lastword $(MAKEFILE_LIST))))target/ 10 | 11 | deps: 12 | go mod tidy 13 | 14 | build: build-wan build-lan 15 | 16 | build-wan: 17 | set GOOS=linux;GOARCH=amd64 18 | go build -ldflags "-s -w" -o target/xxqg_server cmd/wan/main.go 19 | 20 | build-lan: 21 | set GOOS=windows;GOARCH=amd64 22 | go build -ldflags "-s -w -X main.VERSION=v1.0.1" -o target/xxqg_agent.exe cmd/lan/main.go 23 | 24 | compress: 25 | -upx --lzma target/* -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 学习强国功能件…… 仅供学习参考,不得用于商业用途!!! 2 | 3 | # 说明 4 | 项目分为2个服务,一个是本地化跑任务的,一个是放在服务器上用于通信的。 5 | 6 | 目前仅接入的钉钉机器人,需要进入企业开发后台新建机器人应用。 7 | 8 | # 服务端 9 | 负责钉钉机器人的运行,发送消息。将机器人添加如内部群后,可以@机器人,请求一个登录链接,或者 请求统计数据 10 | 11 | # 任务端 12 | 该端用于实际执行任务,放在了本地环境,这样可以确保非服务器IP在学习 13 | 14 | # 使用说明 15 | 请移步wiki [使用说明](https://github.com/yockii/xxqg-automate/wiki/%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E) 16 | 17 | # 鸣谢 18 | 本项目借鉴了以下仓库,感谢作者: 19 | 20 | + [johlanse/study_xxqg](https://github.com/johlanse/study_xxqg) 21 | + [AGou-ops/dingtalk_robot_sample](https://github.com/AGou-ops/dingtalk_robot_sample) 22 | 23 | 钉钉机器人的坑还真是挺多的…… 24 | 25 | 同时感谢我的小伙伴们帮我测试 26 | -------------------------------------------------------------------------------- /ServerDockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | ENTRYPOINT ["/xxqg-server"] 3 | COPY xxqg-server / -------------------------------------------------------------------------------- /cmd/lan/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | 10 | "gitee.com/chunanyong/zorm" 11 | "github.com/panjf2000/ants/v2" 12 | logger "github.com/sirupsen/logrus" 13 | "github.com/yockii/qscore/pkg/config" 14 | "github.com/yockii/qscore/pkg/database" 15 | "github.com/yockii/qscore/pkg/server" 16 | "github.com/yockii/qscore/pkg/task" 17 | 18 | "xxqg-automate/internal/controller/lan" 19 | job "xxqg-automate/internal/job/lan" 20 | "xxqg-automate/internal/study" 21 | "xxqg-automate/internal/update" 22 | 23 | _ "xxqg-automate/internal/initial" 24 | ) 25 | 26 | var VERSION = "" 27 | 28 | var ( 29 | baseUrl string 30 | daemon bool 31 | ) 32 | 33 | func init() { 34 | config.DefaultInstance.SetDefault("server.port", 8080) 35 | config.DefaultInstance.SetDefault("database.type", "sqlite") 36 | config.DefaultInstance.SetDefault("database.address", "./conf/data.db") 37 | config.DefaultInstance.SetDefault("logger.level", "debug") 38 | config.DefaultInstance.SetDefault("xxqg.schema", "https://scintillating-axolotl-8c8432.netlify.app/?") 39 | config.DefaultInstance.SetDefault("xxqg.expireNotify", false) 40 | 41 | flag.StringVar(&baseUrl, "baseUrl", "", "服务端url") 42 | flag.BoolVar(&daemon, "daemon", false, "以守护进程方式启动") 43 | flag.Parse() 44 | 45 | if baseUrl != "" { 46 | config.DefaultInstance.Set("communicate.baseUrl", baseUrl) 47 | } 48 | 49 | // 写入配置文件 50 | if err := config.DefaultInstance.WriteConfig(); err != nil { 51 | logger.Errorln(err) 52 | } 53 | 54 | } 55 | 56 | func main() { 57 | if daemon { 58 | runDaemon(os.Args) 59 | return 60 | } 61 | 62 | defer ants.Release() 63 | logger.Infoln("当前应用版本: " + VERSION) 64 | logger.Infoln("开始检测环境.....") 65 | 66 | study.Init() 67 | defer study.Quit() 68 | database.InitSysDb() 69 | study.LoadLoginJobs() 70 | 71 | // 检查数据库文件是否存在 72 | if config.GetString("database.type") == "sqlite" { 73 | _, err := os.Stat(config.GetString("database.address")) 74 | if err != nil && os.IsNotExist(err) { 75 | // 不存在 76 | f, _ := os.Create(config.GetString("database.address")) 77 | f.Close() 78 | } 79 | 80 | // 创建数据库 81 | createTables() 82 | } 83 | 84 | ants.Submit(func() { 85 | update.CheckUpdate(VERSION) 86 | }) 87 | 88 | //if !util.CheckQuestionDB() { 89 | // util.DownloadDbFile() 90 | //} 91 | 92 | job.InitAutoStudy() 93 | job.InitKeepAlive() 94 | job.InitCommunication() 95 | 96 | task.Start() 97 | defer task.Stop() 98 | 99 | startWeb() 100 | } 101 | 102 | // 以守护进程方式启动 103 | func runDaemon(args []string) { 104 | fmt.Printf("pid:%d ppid: %d, arg: %s \n", os.Getpid(), os.Getppid(), os.Args) 105 | // 去除--daemon参数,启动主程序 106 | for i := 0; i < len(args); { 107 | if args[i] == "--daemon" && i != len(args)-1 { 108 | args = append(args[:i], args[i+1:]...) 109 | } else if args[i] == "--daemon" && i == len(args)-1 { 110 | args = args[:i] 111 | } else { 112 | i++ 113 | } 114 | } 115 | // 启动子进程 116 | for { 117 | cmd := exec.Command(args[0], args[1:]...) 118 | cmd.Stdin = os.Stdin 119 | cmd.Stdout = os.Stdout 120 | cmd.Stderr = os.Stderr 121 | err := cmd.Start() 122 | if err != nil { 123 | fmt.Fprintf(os.Stderr, "启动失败, Error: %s \n", err) 124 | return 125 | } 126 | fmt.Printf("守护进程模式启动学习端, pid:%d ppid: %d, arg: %s \n", cmd.Process.Pid, os.Getpid(), args) 127 | cmd.Wait() 128 | } 129 | } 130 | 131 | func createTables() { 132 | ctx := context.Background() 133 | zorm.Transaction(ctx, func(ctx context.Context) (interface{}, error) { 134 | 135 | userTable := `create table t_user 136 | ( 137 | id varchar(50) 138 | constraint t_user_pk 139 | primary key, 140 | nick varchar(50), 141 | uid varchar(50), 142 | token varchar(500), 143 | login_time INTEGER, 144 | status INTEGER, 145 | create_time datetime, 146 | last_check_time datetime, 147 | last_study_time datetime, 148 | last_finish_time datetime, 149 | last_score INTEGER, 150 | score INTEGER, 151 | only_login_tag INTEGER, 152 | dingtalk_id varchar(50) 153 | );` 154 | zorm.UpdateFinder(ctx, zorm.NewFinder().Append(userTable)) 155 | jobTable := `create table t_job 156 | ( 157 | id varchar(50) 158 | constraint t_job_pk 159 | primary key, 160 | user_id varchar(50), 161 | score INTEGER, 162 | status INTEGER, 163 | code varchar(50), 164 | create_time datetime 165 | );` 166 | zorm.UpdateFinder(ctx, zorm.NewFinder().Append(jobTable)) 167 | 168 | alterUserTable := `alter table t_user add column only_login_tag INTEGER;` 169 | zorm.UpdateFinder(ctx, zorm.NewFinder().Append(alterUserTable)) 170 | 171 | return nil, nil 172 | }) 173 | } 174 | 175 | func startWeb() { 176 | lan.InitRouter() 177 | logger.Error(server.Start(":" + config.GetString("server.port"))) 178 | } 179 | -------------------------------------------------------------------------------- /cmd/wan/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/panjf2000/ants/v2" 10 | logger "github.com/sirupsen/logrus" 11 | "github.com/yockii/qscore/pkg/config" 12 | "github.com/yockii/qscore/pkg/server" 13 | "github.com/yockii/qscore/pkg/task" 14 | 15 | "xxqg-automate/internal/controller/wan" 16 | _ "xxqg-automate/internal/job/wan" 17 | ) 18 | 19 | var daemon bool 20 | 21 | func init() { 22 | config.DefaultInstance.SetDefault("server.port", 8080) 23 | 24 | flag.BoolVar(&daemon, "daemon", false, "以守护进程方式启动") 25 | flag.Parse() 26 | } 27 | 28 | func main() { 29 | 30 | if daemon { 31 | runDaemon(os.Args) 32 | return 33 | } 34 | 35 | defer ants.Release() 36 | 37 | task.Start() 38 | defer task.Stop() 39 | 40 | wan.InitRouter() 41 | logger.Error(server.Start(":" + config.GetString("server.port"))) 42 | } 43 | 44 | // 以守护进程方式启动 45 | func runDaemon(args []string) { 46 | fmt.Printf("pid:%d ppid: %d, arg: %s \n", os.Getpid(), os.Getppid(), os.Args) 47 | // 去除--daemon参数,启动主程序 48 | for i := 0; i < len(args); { 49 | if args[i] == "--daemon" && i != len(args)-1 { 50 | args = append(args[:i], args[i+1:]...) 51 | } else if args[i] == "--daemon" && i == len(args)-1 { 52 | args = args[:i] 53 | } else { 54 | i++ 55 | } 56 | } 57 | // 启动子进程 58 | for { 59 | cmd := exec.Command(args[0], args[1:]...) 60 | cmd.Stdin = os.Stdin 61 | cmd.Stdout = os.Stdout 62 | cmd.Stderr = os.Stderr 63 | err := cmd.Start() 64 | if err != nil { 65 | fmt.Fprintf(os.Stderr, "启动失败, Error: %s \n", err) 66 | return 67 | } 68 | fmt.Printf("守护进程模式启动, pid:%d ppid: %d, arg: %s \n", cmd.Process.Pid, os.Getpid(), args) 69 | cmd.Wait() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /conf/config.toml: -------------------------------------------------------------------------------- 1 | 2 | [communicate] 3 | baseurl = "aaaaaa" 4 | 5 | [database] 6 | address = "./conf/data.db" 7 | type = "sqlite" 8 | 9 | [logger] 10 | level = "debug" 11 | 12 | [server] 13 | port = 8080 14 | pause=false 15 | 16 | [xxqg] 17 | expirenotify = false 18 | schema = "https://scintillating-axolotl-8c8432.netlify.app/?" 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module xxqg-automate 2 | 3 | go 1.19 4 | 5 | require ( 6 | gitee.com/chunanyong/zorm v1.6.3 7 | github.com/dustin/go-humanize v1.0.0 8 | github.com/gofiber/fiber/v2 v2.39.0 9 | github.com/google/uuid v1.3.0 10 | github.com/imroc/req/v3 v3.32.3 11 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 12 | github.com/panjf2000/ants/v2 v2.6.0 13 | github.com/playwright-community/playwright-go v0.2000.1 14 | github.com/prometheus/common v0.7.0 15 | github.com/sirupsen/logrus v1.9.0 16 | github.com/tidwall/gjson v1.14.3 17 | github.com/yockii/qscore v0.0.0-20221201054601-20f54d279ce0 18 | ) 19 | 20 | require ( 21 | gitee.com/chunanyong/dm v1.8.8 // indirect 22 | github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect 23 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 24 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4 // indirect 25 | github.com/andybalholm/brotli v1.0.4 // indirect 26 | github.com/casbin/casbin/v2 v2.39.1 // indirect 27 | github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect 28 | github.com/forgoer/openssl v1.2.1 // indirect 29 | github.com/fsnotify/fsnotify v1.5.4 // indirect 30 | github.com/go-sql-driver/mysql v1.6.0 // indirect 31 | github.com/go-stack/stack v1.8.1 // indirect 32 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect 33 | github.com/gofiber/jwt/v2 v2.2.7 // indirect 34 | github.com/gofiber/template v1.6.20 // indirect 35 | github.com/golang-jwt/jwt/v4 v4.1.0 // indirect 36 | github.com/golang/mock v1.6.0 // indirect 37 | github.com/golang/snappy v0.0.3 // indirect 38 | github.com/gomodule/redigo v1.8.5 // indirect 39 | github.com/google/pprof v0.0.0-20230228050547-1710fef4ab10 // indirect 40 | github.com/hashicorp/errwrap v1.1.0 // indirect 41 | github.com/hashicorp/go-multierror v1.1.1 // indirect 42 | github.com/hashicorp/hcl v1.0.0 // indirect 43 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 44 | github.com/klauspost/compress v1.15.0 // indirect 45 | github.com/lestrrat/go-file-rotatelogs v0.0.0-20180223000712-d3151e2a480f // indirect 46 | github.com/lestrrat/go-strftime v0.0.0-20180220042222-ba3bf9c1d042 // indirect 47 | github.com/lib/pq v1.10.4 // indirect 48 | github.com/magiconair/properties v1.8.5 // indirect 49 | github.com/mattn/go-colorable v0.1.13 // indirect 50 | github.com/mattn/go-isatty v0.0.16 // indirect 51 | github.com/mattn/go-runewidth v0.0.14 // indirect 52 | github.com/mitchellh/mapstructure v1.4.2 // indirect 53 | github.com/onsi/ginkgo/v2 v2.9.0 // indirect 54 | github.com/pelletier/go-toml v1.9.4 // indirect 55 | github.com/pkg/errors v0.8.1 // indirect 56 | github.com/quic-go/qpack v0.4.0 // indirect 57 | github.com/quic-go/qtls-go1-19 v0.2.1 // indirect 58 | github.com/quic-go/qtls-go1-20 v0.1.1 // indirect 59 | github.com/quic-go/quic-go v0.33.0 // indirect 60 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect 61 | github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 // indirect 62 | github.com/rivo/uniseg v0.2.0 // indirect 63 | github.com/robfig/cron/v3 v3.0.1 // indirect 64 | github.com/rs/xid v1.2.1 // indirect 65 | github.com/segmentio/ksuid v1.0.4 // indirect 66 | github.com/spf13/afero v1.6.0 // indirect 67 | github.com/spf13/cast v1.4.1 // indirect 68 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 69 | github.com/spf13/pflag v1.0.5 // indirect 70 | github.com/spf13/viper v1.9.0 // indirect 71 | github.com/subosito/gotenv v1.2.0 // indirect 72 | github.com/tidwall/match v1.1.1 // indirect 73 | github.com/tidwall/pretty v1.2.0 // indirect 74 | github.com/valyala/bytebufferpool v1.0.0 // indirect 75 | github.com/valyala/fasthttp v1.40.0 // indirect 76 | github.com/valyala/tcplisten v1.0.0 // indirect 77 | golang.org/x/crypto v0.7.0 // indirect 78 | golang.org/x/exp v0.0.0-20230304125523-9ff063c70017 // indirect 79 | golang.org/x/mod v0.9.0 // indirect 80 | golang.org/x/net v0.8.0 // indirect 81 | golang.org/x/sys v0.6.0 // indirect 82 | golang.org/x/text v0.8.0 // indirect 83 | golang.org/x/tools v0.6.0 // indirect 84 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect 85 | gopkg.in/ini.v1 v1.63.2 // indirect 86 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect 87 | gopkg.in/yaml.v2 v2.4.0 // indirect 88 | lukechampine.com/uint128 v1.2.0 // indirect 89 | modernc.org/cc/v3 v3.40.0 // indirect 90 | modernc.org/ccgo/v3 v3.16.13 // indirect 91 | modernc.org/libc v1.21.4 // indirect 92 | modernc.org/mathutil v1.5.0 // indirect 93 | modernc.org/memory v1.4.0 // indirect 94 | modernc.org/opt v0.1.3 // indirect 95 | modernc.org/sqlite v1.19.4 // indirect 96 | modernc.org/strutil v1.1.3 // indirect 97 | modernc.org/token v1.0.1 // indirect 98 | ) 99 | -------------------------------------------------------------------------------- /internal/cache/DefaultCache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | var DefaultCache = NewExpiredMap() 4 | -------------------------------------------------------------------------------- /internal/cache/Index.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type val struct { 9 | data interface{} 10 | expiredTime int64 11 | } 12 | 13 | const delChannelCap = 100 14 | 15 | type ExpiredMap struct { 16 | m map[interface{}]*val 17 | timeMap map[int64][]interface{} 18 | lck *sync.Mutex 19 | stop chan struct{} 20 | } 21 | 22 | func NewExpiredMap() *ExpiredMap { 23 | e := ExpiredMap{ 24 | m: make(map[interface{}]*val), 25 | lck: new(sync.Mutex), 26 | timeMap: make(map[int64][]interface{}), 27 | stop: make(chan struct{}), 28 | } 29 | go e.run(time.Now().Unix()) 30 | return &e 31 | } 32 | 33 | type delMsg struct { 34 | keys []interface{} 35 | t int64 36 | } 37 | 38 | // background goroutine 主动删除过期的key 39 | // 数据实际删除时间比应该删除的时间稍晚一些,这个误差会在查询的时候被解决。 40 | func (e *ExpiredMap) run(now int64) { 41 | t := time.NewTicker(time.Second * 1) 42 | defer t.Stop() 43 | delCh := make(chan *delMsg, delChannelCap) 44 | go func() { 45 | for v := range delCh { 46 | e.multiDelete(v.keys, v.t) 47 | } 48 | }() 49 | for { 50 | select { 51 | case <-t.C: 52 | now++ //这里用now++的形式,直接用time.Now().Unix()可能会导致时间跳过1s,导致key未删除。 53 | e.lck.Lock() 54 | if keys, found := e.timeMap[now]; found { 55 | e.lck.Unlock() 56 | delCh <- &delMsg{keys: keys, t: now} 57 | } else { 58 | e.lck.Unlock() 59 | } 60 | case <-e.stop: 61 | close(delCh) 62 | return 63 | } 64 | } 65 | } 66 | 67 | func (e *ExpiredMap) Set(key, value interface{}, expireSeconds int64) { 68 | if expireSeconds <= 0 { 69 | return 70 | } 71 | e.lck.Lock() 72 | defer e.lck.Unlock() 73 | expiredTime := time.Now().Unix() + expireSeconds 74 | e.m[key] = &val{ 75 | data: value, 76 | expiredTime: expiredTime, 77 | } 78 | e.timeMap[expiredTime] = append(e.timeMap[expiredTime], key) //过期时间作为key,放在map中 79 | } 80 | 81 | func (e *ExpiredMap) Get(key interface{}) (found bool, value interface{}) { 82 | e.lck.Lock() 83 | defer e.lck.Unlock() 84 | if found = e.checkDeleteKey(key); !found { 85 | return 86 | } 87 | value = e.m[key].data 88 | return 89 | } 90 | 91 | func (e *ExpiredMap) Delete(key interface{}) { 92 | e.lck.Lock() 93 | delete(e.m, key) 94 | e.lck.Unlock() 95 | } 96 | 97 | func (e *ExpiredMap) Remove(key interface{}) { 98 | e.Delete(key) 99 | } 100 | 101 | func (e *ExpiredMap) multiDelete(keys []interface{}, t int64) { 102 | e.lck.Lock() 103 | defer e.lck.Unlock() 104 | delete(e.timeMap, t) 105 | for _, key := range keys { 106 | delete(e.m, key) 107 | } 108 | } 109 | 110 | func (e *ExpiredMap) Length() int { //结果是不准确的,因为有未删除的key 111 | e.lck.Lock() 112 | defer e.lck.Unlock() 113 | return len(e.m) 114 | } 115 | 116 | func (e *ExpiredMap) Size() int { 117 | return e.Length() 118 | } 119 | 120 | // 返回key的剩余生存时间 key不存在返回负数 121 | func (e *ExpiredMap) TTL(key interface{}) int64 { 122 | e.lck.Lock() 123 | defer e.lck.Unlock() 124 | if !e.checkDeleteKey(key) { 125 | return -1 126 | } 127 | return e.m[key].expiredTime - time.Now().Unix() 128 | } 129 | 130 | func (e *ExpiredMap) Clear() { 131 | e.lck.Lock() 132 | defer e.lck.Unlock() 133 | e.m = make(map[interface{}]*val) 134 | e.timeMap = make(map[int64][]interface{}) 135 | } 136 | 137 | func (e *ExpiredMap) Close() { // todo 关闭后在使用怎么处理 138 | e.lck.Lock() 139 | defer e.lck.Unlock() 140 | e.stop <- struct{}{} 141 | //e.m = nil 142 | //e.timeMap = nil 143 | } 144 | 145 | func (e *ExpiredMap) Stop() { 146 | e.Close() 147 | } 148 | 149 | func (e *ExpiredMap) DoForEach(handler func(interface{}, interface{})) { 150 | e.lck.Lock() 151 | defer e.lck.Unlock() 152 | for k, v := range e.m { 153 | if !e.checkDeleteKey(k) { 154 | continue 155 | } 156 | handler(k, v) 157 | } 158 | } 159 | 160 | func (e *ExpiredMap) DoForEachWithBreak(handler func(interface{}, interface{}) bool) { 161 | e.lck.Lock() 162 | defer e.lck.Unlock() 163 | for k, v := range e.m { 164 | if !e.checkDeleteKey(k) { 165 | continue 166 | } 167 | if handler(k, v) { 168 | break 169 | } 170 | } 171 | } 172 | 173 | func (e *ExpiredMap) checkDeleteKey(key interface{}) bool { 174 | if val, found := e.m[key]; found { 175 | if val.expiredTime <= time.Now().Unix() { 176 | delete(e.m, key) 177 | //delete(e.timeMap, val.expiredTime) 178 | return false 179 | } 180 | return true 181 | } 182 | return false 183 | } 184 | -------------------------------------------------------------------------------- /internal/constant/Cache.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | PrefixJwtSid = "jwtSid:" 5 | ) 6 | -------------------------------------------------------------------------------- /internal/constant/Config.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | ConfigServerPause = "server.pause" 5 | ) 6 | -------------------------------------------------------------------------------- /internal/constant/Default.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | import "github.com/yockii/qscore/pkg/config" 4 | 5 | const ( 6 | DefaultRoleId = "999999999" 7 | DefaultRoleName = "超级管理员" 8 | ResourceTypeRoute = "route" 9 | ) 10 | 11 | const ( 12 | QuestionBankSourceName = "questionBank" 13 | QuestionBankDBFile = "./conf/QuestionBank.db" 14 | ) 15 | 16 | var ( 17 | CommunicateHeaderKey = config.GetString("server.token") 18 | ) 19 | -------------------------------------------------------------------------------- /internal/constant/Dingtalk.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | DingtalkOApiBaseUrl = "https://oapi.dingtalk.com" 5 | DingtalkApiBaseUrl = "https://api.dingtalk.com" 6 | ) 7 | 8 | const ( 9 | DingtalkAccessToken = "dingtalk:accessToken" 10 | ) 11 | -------------------------------------------------------------------------------- /internal/constant/Xxqg.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | XxqgUrlUserInfo = "https://pc-api.xuexi.cn/open/api/user/info" 5 | XxqgUrlTotalScore = "https://pc-api.xuexi.cn/open/api/score/get" 6 | XxqgUrlTodayTotalScore = "https://pc-api.xuexi.cn/open/api/score/today/query" 7 | XxqgUrlRateScore = "https://pc-proxy-api.xuexi.cn/api/score/days/listScoreProgress?sence=score&deviceType=2" 8 | 9 | XxqgUrlMyPoints = "https://pc.xuexi.cn/points/my-points.html" 10 | XxqgUrlWeekList = "https://pc-proxy-api.xuexi.cn/api/exam/service/practice/pc/weekly/more" 11 | XxqgUrlSpecialList = "https://pc-proxy-api.xuexi.cn/api/exam/service/paper/pc/list" 12 | 13 | XxqgUrlWeekAnswerPage = "https://pc.xuexi.cn/points/exam-weekly-detail.html?id=%d" 14 | XxqgUrlSpecialAnswerPage = "https://pc.xuexi.cn/points/exam-paper-detail.html?id=%d" 15 | ) 16 | 17 | const ( 18 | Article = "article" 19 | Video = "video" 20 | ) 21 | 22 | var ( 23 | ArticleUrlList = []string{ 24 | "https://www.xuexi.cn/lgdata/35il6fpn0ohq.json", 25 | "https://www.xuexi.cn/lgdata/45a3hac2bf1j.json", 26 | "https://www.xuexi.cn/lgdata/1ajhkle8l72.json", 27 | "https://www.xuexi.cn/lgdata/1ahjpjgb4n3.json", 28 | "https://www.xuexi.cn/lgdata/1je1objnh73.json", 29 | "https://www.xuexi.cn/lgdata/1kvrj9vvv73.json", 30 | "https://www.xuexi.cn/lgdata/17qonfb74n3.json", 31 | "https://www.xuexi.cn/lgdata/1i30sdhg0n3.json", 32 | } 33 | 34 | VideoUrlList = []string{ 35 | "https://www.xuexi.cn/lgdata/3j2u3cttsii9.json", 36 | "https://www.xuexi.cn/lgdata/1novbsbi47k.json", 37 | "https://www.xuexi.cn/lgdata/31c9ca1tgfqb.json", 38 | "https://www.xuexi.cn/lgdata/1oajo2vt47l.json", 39 | "https://www.xuexi.cn/lgdata/18rkaul9h7l.json", 40 | "https://www.xuexi.cn/lgdata/2qfjjjrprmdh.json", 41 | "https://www.xuexi.cn/lgdata/3o3ufqgl8rsn.json", 42 | "https://www.xuexi.cn/lgdata/525pi8vcj24p.json", 43 | "https://www.xuexi.cn/lgdata/1742g60067k.json", 44 | } 45 | ) 46 | -------------------------------------------------------------------------------- /internal/controller/lan/Index.go: -------------------------------------------------------------------------------- 1 | package lan 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/yockii/qscore/pkg/server" 6 | 7 | "xxqg-automate/internal/service" 8 | ) 9 | 10 | func InitRouter() { 11 | 12 | server.Get("/api/v1/redirectXxqg", UserController.AutoLoginXxqg) 13 | 14 | server.Get("/api/v1/getRedirectXxqg", UserController.GetRedirectUrl) 15 | 16 | server.Get("/api/v1/users", UserController.GetUserList) 17 | 18 | server.Get("/api/v1/statisticsInfo", func(ctx *fiber.Ctx) error { 19 | info := service.GetStatisticsInfo() 20 | return ctx.JSON(info) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /internal/controller/lan/UserController.go: -------------------------------------------------------------------------------- 1 | package lan 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | 6 | "xxqg-automate/internal/service" 7 | "xxqg-automate/internal/study" 8 | ) 9 | 10 | var UserController = new(userController) 11 | 12 | type userController struct{} 13 | 14 | func (c *userController) AutoLoginXxqg(ctx *fiber.Ctx) error { 15 | u, err := study.GetXxqgRedirectUrl() 16 | if err != nil { 17 | return ctx.SendStatus(fiber.StatusBadRequest) 18 | } 19 | return ctx.Redirect(u) 20 | } 21 | 22 | func (c *userController) GetRedirectUrl(ctx *fiber.Ctx) error { 23 | u, err := study.GetXxqgRedirectUrl() 24 | if err != nil { 25 | return ctx.SendStatus(fiber.StatusBadRequest) 26 | } 27 | return ctx.SendString(u) 28 | } 29 | 30 | func (c *userController) GetUserList(ctx *fiber.Ctx) error { 31 | users, err := service.UserService.List(ctx.UserContext()) 32 | if err != nil { 33 | return ctx.SendStatus(fiber.StatusBadRequest) 34 | } 35 | return ctx.JSON(users) 36 | } 37 | -------------------------------------------------------------------------------- /internal/controller/wan/Index.go: -------------------------------------------------------------------------------- 1 | package wan 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | 12 | "github.com/gofiber/fiber/v2" 13 | logger "github.com/sirupsen/logrus" 14 | "github.com/yockii/qscore/pkg/config" 15 | "github.com/yockii/qscore/pkg/server" 16 | 17 | "xxqg-automate/internal/cache" 18 | "xxqg-automate/internal/constant" 19 | "xxqg-automate/internal/domain" 20 | job "xxqg-automate/internal/job/wan" 21 | "xxqg-automate/internal/util" 22 | ) 23 | 24 | // 给钉钉的连接 POST /dingtalk 25 | 26 | // 1、钉钉机器人记录谁需要连接,并在收到连接后依次通知 27 | // 2、接收完成通知 28 | // 3、需要统计数据 29 | var loginReq []string 30 | var needStatistics atomic.Bool 31 | var bindUser = make(map[string]string) // key=钉钉id value=名字 32 | var manualStudy []string // 主动学习名单 33 | var locker sync.Mutex 34 | var tagUser []string // 标记用户名单,此类用户只登录,不学习 35 | 36 | func InitRouter() { 37 | 38 | // 接收钉钉回调 39 | handleDingtalkCall() 40 | 41 | // 接收客户端定时轮询 42 | handleStatusAsk() 43 | 44 | // 接收客户端请求 45 | handleFinishNotify() 46 | handleLinkNotify() 47 | handleStatisticsNotify() 48 | handleExpiredNotify() 49 | handleLoginSuccessNotify() 50 | handleBindSuccessNotify() 51 | handleSendDingRequest() 52 | } 53 | 54 | func handleSendDingRequest() { 55 | server.Post("/api/v1/sendToDingtalkUser", func(ctx *fiber.Ctx) error { 56 | req := new(domain.SendToDingUser) 57 | if err := ctx.BodyParser(req); err != nil { 58 | logger.Errorln(err) 59 | return ctx.SendStatus(fiber.StatusBadRequest) 60 | } 61 | sendToDingUser(req.UserId, req.MsgKey, req.MsgParam) 62 | return ctx.SendStatus(fiber.StatusOK) 63 | }) 64 | } 65 | 66 | func dingtalkSign(timestamp, secret string) string { 67 | needSign := timestamp + "\n" + secret 68 | signedBytes := util.HmacSha256([]byte(needSign), []byte(config.GetString("dingtalk.appSecret"))) 69 | return base64.StdEncoding.EncodeToString(signedBytes) 70 | } 71 | 72 | func handleDingtalkCall() { 73 | type dingtalkCallBody struct { 74 | SenderStaffId string `json:"senderStaffId,omitempty"` 75 | SenderNick string `json:"senderNick,omitempty"` 76 | Text struct { 77 | Content string `json:"content,omitempty"` 78 | } `json:"text"` 79 | } 80 | 81 | server.Post("/dingtalk", func(ctx *fiber.Ctx) error { 82 | timestampStr := ctx.Get("timestamp") 83 | sign := ctx.Get("sign") 84 | if timestampStr == "" || sign == "" { 85 | return ctx.SendStatus(fiber.StatusBadRequest) 86 | } 87 | timestamp, _ := strconv.ParseInt(timestampStr, 10, 64) 88 | nowTimestamp := time.Now().UnixMilli() 89 | if nowTimestamp > timestamp+60*60*1000 || nowTimestamp < timestamp-60*60*1000 { 90 | return ctx.SendStatus(fiber.StatusBadRequest) 91 | } 92 | // 签名计算 93 | mySigned := dingtalkSign(timestampStr, config.GetString("dingtalk.appSecret")) 94 | if mySigned != sign { 95 | return ctx.SendStatus(fiber.StatusBadRequest) 96 | } 97 | 98 | // 验证确定来自钉钉的请求 99 | data := new(dingtalkCallBody) 100 | if err := ctx.BodyParser(data); err != nil { 101 | logger.Errorln(err) 102 | return ctx.SendStatus(fiber.StatusBadRequest) 103 | } 104 | 105 | if strings.Contains(data.Text.Content, "帮助") { 106 | sendToDingtalk("", `机器人指令:"帮助": 显示可用指令集; 107 | "暂停": 暂停接收指令(与启动不能同时接收); 108 | "启动": 恢复接收指令(与暂停不能同时接收); 109 | "登录": 获取登录链接(需安装app进行跳转); 110 | "统计": 统计机器人学习情况; 111 | "绑定": 绑定名称(如有些人app中的名称与钉钉不一致); 112 | "学习": 立即开始学习; 113 | "只登标记": 标记当前账号只登录不学习; `, 1) 114 | } 115 | 116 | if strings.Contains(data.Text.Content, "暂停") { 117 | // 暂停接收 118 | config.DefaultInstance.Set(constant.ConfigServerPause, true) 119 | _ = config.DefaultInstance.SafeWriteConfig() 120 | // 发送钉钉消息 121 | sendToDingtalk("", "已暂停接受指令, 执行中的任务不受影响,要恢复请@我并发送“启动”", 1) 122 | } else if strings.Contains(data.Text.Content, "启动") { 123 | config.DefaultInstance.Set(constant.ConfigServerPause, false) 124 | _ = config.DefaultInstance.SafeWriteConfig() 125 | // 发送钉钉消息 126 | sendToDingtalk("", "已恢复机器人指令接收", 1) 127 | } 128 | 129 | if config.GetBool(constant.ConfigServerPause) { 130 | // 发送钉钉消息 131 | sendToDingtalk("", "机器人指令暂停接收中, 要回复请@我并发送'启动'", 1) 132 | return ctx.SendStatus(fiber.StatusOK) 133 | } 134 | 135 | if strings.Contains(data.Text.Content, "登录") { 136 | locker.Lock() 137 | defer locker.Unlock() 138 | loginReq = append(loginReq, data.SenderStaffId) 139 | } 140 | if strings.Contains(data.Text.Content, "统计") { 141 | needStatistics.Store(true) 142 | } 143 | if strings.Contains(data.Text.Content, "绑定") { 144 | nick := strings.TrimSpace(strings.ReplaceAll(data.Text.Content, "绑定 ", "")) 145 | if nick == "" { 146 | nick = data.SenderNick 147 | } 148 | bindUser[data.SenderStaffId] = nick 149 | } 150 | if strings.Contains(data.Text.Content, "学习") { 151 | // 进行主动学习 152 | manualStudy = append(manualStudy, data.SenderStaffId) 153 | // 发送钉钉消息 154 | sendToDingtalk("", "已收到学习请求,请等待处理", 1) 155 | } 156 | if strings.Contains(data.Text.Content, "只登标记") { 157 | tagUser = append(tagUser, data.SenderStaffId) 158 | // 发送钉钉消息 159 | sendToDingtalk("", "已收到只登录不学习请求,请等待处理", 1) 160 | } 161 | return ctx.SendStatus(fiber.StatusOK) 162 | }) 163 | } 164 | 165 | func handleStatusAsk() { 166 | server.Get("/api/v1/status", checkToken, func(ctx *fiber.Ctx) error { 167 | locker.Lock() 168 | defer locker.Unlock() 169 | // 立即学习名单 170 | needStudy := manualStudy 171 | manualStudy = []string{} 172 | // 标记只登录用户 173 | needTag := tagUser 174 | tagUser = []string{} 175 | // 需要登录链接的单个用户 176 | loginDingId := "" 177 | needLink := len(loginReq) > 0 178 | if needLink { 179 | loginDingId = loginReq[0] 180 | } 181 | return ctx.JSON(domain.StatusAsk{ 182 | NeedLink: needLink, 183 | LinkDingId: loginDingId, 184 | NeedStatistics: needStatistics.Load(), 185 | BindUsers: bindUser, 186 | StartStudy: needStudy, 187 | TagUsers: needTag, 188 | }) 189 | }) 190 | } 191 | 192 | func checkToken(ctx *fiber.Ctx) error { 193 | if ctx.Get("token") != constant.CommunicateHeaderKey { 194 | return ctx.SendStatus(fiber.StatusForbidden) 195 | } 196 | return ctx.Next() 197 | } 198 | 199 | func handleStatisticsNotify() { 200 | server.Post("/api/v1/statisticsNotify", checkToken, func(ctx *fiber.Ctx) error { 201 | info := new(domain.StatisticsInfo) 202 | if err := ctx.BodyParser(info); err != nil { 203 | logger.Errorln(err) 204 | return ctx.SendStatus(fiber.StatusBadRequest) 205 | } 206 | needStatistics.Store(false) 207 | // 发送到钉钉 208 | sendToDingtalk("", fmt.Sprintf( 209 | "已完成[%d]: %s \n 学习中[%d]: %s \n 等待学习[%d]: %s \n 已失效[%d]: %s \n 未完成[%d]: %s", 210 | len(info.Finished), 211 | strings.Join(info.Finished, ","), 212 | len(info.Studying), 213 | strings.Join(info.Studying, ","), 214 | len(info.Waiting), 215 | strings.Join(info.Waiting, ","), 216 | len(info.Expired), 217 | strings.Join(info.Expired, ","), 218 | len(info.NotFinished), 219 | strings.Join(info.NotFinished, ","), 220 | ), 221 | 1) 222 | return ctx.SendStatus(fiber.StatusOK) 223 | }) 224 | } 225 | 226 | func handleLinkNotify() { 227 | server.Post("/api/v1/newLink", checkToken, func(ctx *fiber.Ctx) error { 228 | l := new(domain.LinkInfo) 229 | if err := ctx.BodyParser(l); err != nil { 230 | logger.Errorln(err) 231 | return ctx.SendStatus(fiber.StatusBadRequest) 232 | } 233 | locker.Lock() 234 | defer locker.Unlock() 235 | atUserId := "" 236 | if len(loginReq) > 0 { 237 | atUserId = loginReq[0] 238 | if len(loginReq) == 1 { 239 | loginReq = []string{} 240 | } else { 241 | loginReq = loginReq[1:] 242 | } 243 | } 244 | sendToDingtalk(atUserId, l.Link, 2) 245 | return ctx.SendStatus(fiber.StatusOK) 246 | }) 247 | } 248 | 249 | func handleFinishNotify() { 250 | server.Post("/api/v1/finishNotify", checkToken, func(ctx *fiber.Ctx) error { 251 | info := new(domain.FinishInfo) 252 | if err := ctx.BodyParser(info); err != nil { 253 | logger.Errorln(err) 254 | return ctx.SendStatus(fiber.StatusBadRequest) 255 | } 256 | sendToDingtalk("", fmt.Sprintf("%s已完成学习,积分:%d", info.Nick, info.Score), 1) 257 | return ctx.SendStatus(fiber.StatusOK) 258 | }) 259 | } 260 | 261 | func handleExpiredNotify() { 262 | server.Post("/api/v1/expiredNotify", checkToken, func(ctx *fiber.Ctx) error { 263 | info := new(domain.NotifyInfo) 264 | if err := ctx.BodyParser(info); err != nil { 265 | logger.Errorln(err) 266 | return ctx.SendStatus(fiber.StatusBadRequest) 267 | } 268 | 269 | sendToDingtalk("", fmt.Sprintf("%s登录已失效,请重新发送登录获取登录连接", info.Nick), 1) 270 | return ctx.SendStatus(fiber.StatusOK) 271 | }) 272 | } 273 | 274 | func handleLoginSuccessNotify() { 275 | server.Post("/api/v1/loginSuccessNotify", checkToken, func(ctx *fiber.Ctx) error { 276 | info := new(domain.NotifyInfo) 277 | if err := ctx.BodyParser(info); err != nil { 278 | logger.Errorln(err) 279 | return ctx.SendStatus(fiber.StatusBadRequest) 280 | } 281 | 282 | sendToDingtalk("", fmt.Sprintf("%s登录成功", info.Nick), 1) 283 | return ctx.SendStatus(fiber.StatusOK) 284 | }) 285 | } 286 | 287 | func handleBindSuccessNotify() { 288 | server.Post("/api/v1/bindSuccessNotify", func(ctx *fiber.Ctx) error { 289 | info := new(domain.NotifyInfo) 290 | if err := ctx.BodyParser(info); err != nil { 291 | logger.Errorln(err) 292 | return ctx.SendStatus(fiber.StatusBadRequest) 293 | } 294 | successStr := "失败" 295 | if info.Success { 296 | successStr = "成功" 297 | } 298 | 299 | dingtalkUserId := "" 300 | 301 | for dingtalkId, nick := range bindUser { 302 | if nick == info.Nick { 303 | dingtalkUserId = dingtalkId 304 | delete(bindUser, dingtalkId) 305 | break 306 | } 307 | } 308 | 309 | sendToDingtalk(dingtalkUserId, fmt.Sprintf("%s绑定%s", info.Nick, successStr), 1) 310 | return ctx.SendStatus(fiber.StatusOK) 311 | }) 312 | } 313 | 314 | func sendToDingtalk(atUserId string, content string, t int) { 315 | switch t { 316 | case 1: // 普通文本型 317 | sendCommonText(atUserId, content) 318 | case 2: // 链接 319 | sendLinkedMsg(atUserId, content) 320 | } 321 | } 322 | 323 | type DingtalkTextMessage struct { 324 | MsgType string `json:"msgtype,omitempty"` 325 | Text struct { 326 | Content string `json:"content,omitempty"` 327 | } `json:"text"` 328 | At struct { 329 | AtMobiles []string `json:"atMobiles,omitempty"` 330 | AtUserIds []string `json:"atDingtalkIds,omitempty"` 331 | IsAtAll bool `json:"isAtAll,omitempty"` 332 | } `json:"at"` 333 | } 334 | 335 | func sendCommonText(atUserId string, content string) { 336 | apiUrl := constant.DingtalkOApiBaseUrl + "/robot/send" 337 | //t := fmt.Sprintf("%d", time.Now().UnixMilli()) 338 | 339 | _, _ = util.GetClient().R(). 340 | SetHeader("Content-Type", "application/json"). 341 | SetQueryParams(map[string]string{ 342 | //"timestamp": t, 343 | //"sign": dingtalkSign(t, config.GetString("dingtalk.appSecret")), 344 | "access_token": config.GetString("dingtalk.accessToken"), 345 | }). 346 | SetBody(&DingtalkTextMessage{ 347 | MsgType: "text", 348 | Text: struct { 349 | Content string `json:"content,omitempty"` 350 | }{ 351 | Content: "[学习强国] " + content, 352 | }, 353 | At: struct { 354 | AtMobiles []string `json:"atMobiles,omitempty"` 355 | AtUserIds []string `json:"atDingtalkIds,omitempty"` 356 | IsAtAll bool `json:"isAtAll,omitempty"` 357 | }{ 358 | AtUserIds: []string{atUserId}, 359 | }, 360 | }). 361 | Post(apiUrl) 362 | } 363 | 364 | type DingtalkMarkdownMessage struct { 365 | MsgType string `json:"msgtype,omitempty"` 366 | Markdown struct { 367 | Title string `json:"title"` 368 | Text string `json:"text"` 369 | } `json:"markdown"` 370 | At struct { 371 | AtMobiles []string `json:"atMobiles,omitempty"` 372 | AtUserIds []string `json:"atUserIds,omitempty"` 373 | IsAtAll bool `json:"isAtAll,omitempty"` 374 | } `json:"at"` 375 | } 376 | 377 | func sendLinkedMsg(atUserId string, link string) { 378 | apiUrl := constant.DingtalkOApiBaseUrl + "/robot/send" 379 | //t := fmt.Sprintf("%d", time.Now().UnixMilli()) 380 | 381 | _, err := util.GetClient().R(). 382 | SetHeader("Content-Type", "application/json"). 383 | SetQueryParams(map[string]string{ 384 | //"timestamp": t, 385 | //"sign": dingtalkSign(t, config.GetString("dingtalk.appSecret")), 386 | "access_token": config.GetString("dingtalk.accessToken"), 387 | }). 388 | SetBody(&DingtalkMarkdownMessage{ 389 | MsgType: "markdown", 390 | Markdown: struct { 391 | Title string `json:"title"` 392 | Text string `json:"text"` 393 | }{ 394 | Title: "学习强国登录", 395 | Text: fmt.Sprintf("[学习强国] @%s [点击登录学习强国](%s)", atUserId, link), 396 | }, 397 | At: struct { 398 | AtMobiles []string `json:"atMobiles,omitempty"` 399 | AtUserIds []string `json:"atUserIds,omitempty"` 400 | IsAtAll bool `json:"isAtAll,omitempty"` 401 | }{ 402 | AtUserIds: []string{atUserId}, 403 | }, 404 | }). 405 | Post(apiUrl) 406 | if err != nil { 407 | logger.Errorln(err) 408 | return 409 | } 410 | } 411 | 412 | func sendToDingUser(userId string, msgType string, content string) { 413 | accessToken := "" 414 | found, at := cache.DefaultCache.Get(constant.DingtalkAccessToken) 415 | if !found { 416 | accessToken = job.RefreshAccessToken() 417 | } else { 418 | accessToken = at.(string) 419 | } 420 | resp, err := util.GetClient().R().SetHeader("x-acs-dingtalk-access-token", accessToken).SetBody(map[string]interface{}{ 421 | "robotCode": config.GetString("dingtalk.appKey"), 422 | "userIds": []string{userId}, 423 | "msgKey": msgType, 424 | "msgParam": content, 425 | }).Post(constant.DingtalkApiBaseUrl + "/v1.0/robot/oToMessages/batchSend") 426 | if err != nil { 427 | logger.Errorln(err) 428 | return 429 | } 430 | logger.Debugf(resp.ToString()) 431 | } 432 | -------------------------------------------------------------------------------- /internal/domain/Communication.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type StatusAsk struct { 4 | NeedLink bool `json:"needLink,omitempty"` 5 | NeedStatistics bool `json:"needStatistics,omitempty"` 6 | BindUsers map[string]string `json:"bindUsers"` 7 | LinkDingId string `json:"linkDingId,omitempty"` 8 | StartStudy []string `json:"startStudy,omitempty"` 9 | TagUsers []string `json:"tagUsers,omitempty"` 10 | } 11 | 12 | type StatisticsInfo struct { 13 | Finished []string `json:"finished,omitempty"` 14 | Studying []string `json:"studying,omitempty"` 15 | Expired []string `json:"expired,omitempty"` 16 | Waiting []string `json:"waiting,omitempty"` 17 | NotFinished []string `json:"notFinished,omitempty"` 18 | } 19 | 20 | type LinkInfo struct { 21 | Link string `json:"link"` 22 | } 23 | 24 | type FinishInfo struct { 25 | Nick string `json:"nick,omitempty"` 26 | Score int `json:"score,omitempty"` 27 | } 28 | 29 | type NotifyInfo struct { 30 | Nick string `json:"nick,omitempty"` 31 | Success bool `json:"success"` 32 | } 33 | 34 | type SendToDingUser struct { 35 | UserId string `json:"userId,omitempty"` 36 | MsgKey string `json:"msgKey,omitempty"` 37 | MsgParam string `json:"msgParam,omitempty"` 38 | } 39 | -------------------------------------------------------------------------------- /internal/domain/User.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "xxqg-automate/internal/model" 4 | 5 | type UserLoginReq struct { 6 | Username string `json:"username,omitempty"` 7 | Password string `json:"password,omitempty"` 8 | } 9 | 10 | type UserLoginResp struct { 11 | User *model.User `json:"user,omitempty"` 12 | Token string `json:"token,omitempty"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/initial/Index.go: -------------------------------------------------------------------------------- 1 | package initial 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func init() { 8 | time.Local = time.FixedZone("CST", 8*3600) 9 | InitQuestionBankDB() 10 | } 11 | -------------------------------------------------------------------------------- /internal/initial/QuestionBank.go: -------------------------------------------------------------------------------- 1 | package initial 2 | 3 | import ( 4 | "github.com/yockii/qscore/pkg/database" 5 | 6 | "xxqg-automate/internal/constant" 7 | ) 8 | 9 | func InitQuestionBankDB() { 10 | database.InitSqlite(constant.QuestionBankSourceName, constant.QuestionBankDBFile) 11 | } 12 | -------------------------------------------------------------------------------- /internal/job/lan/AutoKeepAliveJob.go: -------------------------------------------------------------------------------- 1 | package lan 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "time" 7 | 8 | "gitee.com/chunanyong/zorm" 9 | "github.com/panjf2000/ants/v2" 10 | logger "github.com/sirupsen/logrus" 11 | "github.com/yockii/qscore/pkg/config" 12 | "github.com/yockii/qscore/pkg/domain" 13 | "github.com/yockii/qscore/pkg/task" 14 | 15 | "xxqg-automate/internal/constant" 16 | internalDomain "xxqg-automate/internal/domain" 17 | "xxqg-automate/internal/model" 18 | "xxqg-automate/internal/study" 19 | "xxqg-automate/internal/util" 20 | ) 21 | 22 | func InitKeepAlive() { 23 | task.AddFunc("0 0/2 7-22 * * *", keepAlive) 24 | } 25 | 26 | func keepAlive() { 27 | lastTime := time.Now().Add(-4 * time.Hour) 28 | var users []*model.User 29 | if err := zorm.Query(context.Background(), 30 | zorm.NewSelectFinder(model.UserTableName).Append("WHERE (last_check_time is null or last_check_time0", lastTime), 31 | &users, 32 | nil, 33 | ); err != nil { 34 | logger.Error(err) 35 | return 36 | } 37 | for _, user := range users { 38 | ants.Submit(func() { 39 | doKeepAlive(user) 40 | }) 41 | } 42 | } 43 | 44 | func doKeepAlive(user *model.User) { 45 | time.Sleep(time.Duration(rand.Int63n(500)) * time.Second) 46 | score := study.Core.Score(user) 47 | failed := score == nil || score.TotalScore == 0 48 | var err error 49 | if failed { 50 | logger.Warnf("未能成功获取到用户%s的积分,用户将置为失效状态", user.Nick) 51 | } 52 | if failed { 53 | zorm.Transaction(context.Background(), func(ctx context.Context) (interface{}, error) { 54 | _, err = zorm.UpdateNotZeroValue(ctx, &model.User{ 55 | Id: user.Id, 56 | LastCheckTime: domain.DateTime(time.Now()), 57 | Status: -1, 58 | }) 59 | if err != nil { 60 | return nil, err 61 | } 62 | return zorm.Delete(ctx, &model.Job{UserId: user.Id, Status: 1}) 63 | }) 64 | if config.GetString("communicate.baseUrl") != "" { 65 | if config.GetBool("xxqg.expireNotify") { 66 | util.GetClient().R(). 67 | SetHeader("token", constant.CommunicateHeaderKey). 68 | SetBody(&internalDomain.NotifyInfo{ 69 | Nick: user.Nick, 70 | }).Post(config.GetString("communicate.baseUrl") + "/api/v1/expiredNotify") 71 | } 72 | } 73 | } else { 74 | zorm.Transaction(context.Background(), func(ctx context.Context) (interface{}, error) { 75 | return zorm.UpdateNotZeroValue(ctx, &model.User{ 76 | Id: user.Id, 77 | LastCheckTime: domain.DateTime(time.Now()), 78 | }) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /internal/job/lan/AutoStudyJob.go: -------------------------------------------------------------------------------- 1 | package lan 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "gitee.com/chunanyong/zorm" 8 | "github.com/panjf2000/ants/v2" 9 | logger "github.com/sirupsen/logrus" 10 | "github.com/yockii/qscore/pkg/config" 11 | "github.com/yockii/qscore/pkg/domain" 12 | "github.com/yockii/qscore/pkg/task" 13 | 14 | "xxqg-automate/internal/constant" 15 | internalDomain "xxqg-automate/internal/domain" 16 | "xxqg-automate/internal/model" 17 | "xxqg-automate/internal/service" 18 | "xxqg-automate/internal/study" 19 | "xxqg-automate/internal/util" 20 | ) 21 | 22 | func InitAutoStudy() { 23 | // 加载已有的job 24 | loadJobs() 25 | _, _ = task.AddFunc("0 0/3 6-23 * * *", func() { 26 | // 1、查出需要执行学习的用户 27 | lastTime := time.Now().Add(-20 * time.Hour) 28 | var users []*model.User 29 | if err := zorm.Query(context.Background(), 30 | zorm.NewSelectFinder(model.UserTableName).Append("WHERE (last_study_time is null or last_study_time0 and only_login_tag <> 1", lastTime), 31 | &users, 32 | nil, 33 | ); err != nil { 34 | logger.Errorln(err) 35 | return 36 | } 37 | 38 | // 查出间隔1小时以上未完成学习的,重新学习 39 | lastTime = time.Now().Add(-1 * time.Hour) 40 | var notFinished []*model.User 41 | if err := zorm.Query(context.Background(), 42 | zorm.NewSelectFinder(model.UserTableName).Append( 43 | "WHERE last_study_time>? and last_study_time0 and only_login_tag <> 1", 44 | time.Now().Format("2006-01-02"), 45 | lastTime), 46 | ¬Finished, 47 | nil, 48 | ); err != nil { 49 | logger.Errorln(err) 50 | } 51 | users = append(users, notFinished...) 52 | 53 | // 开始学习 54 | for _, user := range users { 55 | if user.Token != "" { 56 | if ok, _ := study.CheckUserCookie(study.TokenToCookies(user.Token)); ok { 57 | _ = ants.Submit(func() { 58 | study.StartStudy(user) 59 | }) 60 | } else { 61 | logger.Warnln("用户登录信息已失效", user.Nick) 62 | _, _ = zorm.Transaction(context.Background(), func(ctx context.Context) (interface{}, error) { 63 | _, err := zorm.UpdateNotZeroValue(ctx, &model.User{ 64 | Id: user.Id, 65 | LastCheckTime: domain.DateTime(time.Now()), 66 | Status: -1, 67 | }) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return zorm.Delete(ctx, &model.Job{UserId: user.Id, Status: 1}) 72 | }) 73 | if config.GetString("communicate.baseUrl") != "" { 74 | if config.GetBool("xxqg.expireNotify") { 75 | _, _ = util.GetClient().R(). 76 | SetHeader("token", constant.CommunicateHeaderKey). 77 | SetBody(&internalDomain.NotifyInfo{ 78 | Nick: user.Nick, 79 | }).Post(config.GetString("communicate.baseUrl") + "/api/v1/expiredNotify") 80 | } 81 | } 82 | } 83 | } 84 | } 85 | }) 86 | } 87 | 88 | func loadJobs() { 89 | jobs, err := service.JobService.FindList( 90 | context.Background(), 91 | zorm.NewSelectFinder(model.JobTableName).Append("WHERE status=1"), 92 | nil, 93 | ) 94 | if err != nil { 95 | logger.Errorln(err) 96 | return 97 | } 98 | for _, job := range jobs { 99 | uid := job.UserId 100 | user, err := service.UserService.GetById(context.Background(), uid) 101 | if err != nil { 102 | logger.Errorln(err) 103 | continue 104 | } 105 | _ = ants.Submit(func() { 106 | study.StartStudy(user, job) 107 | }) 108 | time.Sleep(time.Second) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/job/lan/CheckCompanyIp.go: -------------------------------------------------------------------------------- 1 | package lan 2 | 3 | import ( 4 | "github.com/yockii/qscore/pkg/config" 5 | "github.com/yockii/qscore/pkg/task" 6 | 7 | "xxqg-automate/internal/util" 8 | ) 9 | 10 | func init() { 11 | if config.GetString("server.ipWhiteList") != "" { 12 | task.AddFunc("@every 1h30m", func() { 13 | util.GetClient().Get("http://192.168.1.8:31558/") 14 | }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/job/lan/CommunicationJob.go: -------------------------------------------------------------------------------- 1 | package lan 2 | 3 | import ( 4 | "context" 5 | "time" 6 | "xxqg-automate/internal/model" 7 | 8 | "github.com/panjf2000/ants/v2" 9 | logger "github.com/sirupsen/logrus" 10 | "github.com/yockii/qscore/pkg/config" 11 | 12 | "xxqg-automate/internal/constant" 13 | "xxqg-automate/internal/domain" 14 | "xxqg-automate/internal/service" 15 | "xxqg-automate/internal/study" 16 | "xxqg-automate/internal/util" 17 | ) 18 | 19 | func InitCommunication() { 20 | if config.GetString("communicate.baseUrl") != "" { 21 | // 不用task,而采用延迟的方式 22 | ants.Submit(fetchServerInfo) 23 | } 24 | } 25 | 26 | func fetchServerInfo() { 27 | time.Sleep(5 * time.Second) 28 | defer ants.Submit(fetchServerInfo) 29 | result := new(domain.StatusAsk) 30 | _, err := util.GetClient().R(). 31 | SetHeader("token", constant.CommunicateHeaderKey). 32 | SetResult(result). 33 | Get(config.GetString("communicate.baseUrl") + "/api/v1/status") 34 | if err != nil { 35 | logger.Errorln(err) 36 | return 37 | } 38 | //logger.Debugln(resp.ToString()) 39 | if result.NeedLink { 40 | logger.Debugln("需要新的登录链接") 41 | var link string 42 | link, err = study.GetXxqgRedirectUrl(result.LinkDingId) 43 | if err != nil { 44 | logger.Error(err) 45 | } else { 46 | resp, _ := util.GetClient().R(). 47 | SetHeader("token", constant.CommunicateHeaderKey). 48 | SetBody(&domain.LinkInfo{Link: link}).Post(config.GetString("communicate.baseUrl") + "/api/v1/newLink") 49 | logger.Debugln(resp.ToString()) 50 | } 51 | } 52 | if result.NeedStatistics { 53 | logger.Debugln("需要统计信息") 54 | // 查询统计信息,今日完成情况 55 | info := service.GetStatisticsInfo() 56 | util.GetClient().R(). 57 | SetHeader("token", constant.CommunicateHeaderKey). 58 | SetBody(info).Post(config.GetString("communicate.baseUrl") + "/api/v1/statisticsNotify") 59 | } 60 | 61 | if len(result.BindUsers) > 0 { 62 | // 绑定用户 63 | for dingtalkId, nick := range result.BindUsers { 64 | service.UserService.BindUser(nick, dingtalkId) 65 | } 66 | } 67 | 68 | if len(result.StartStudy) > 0 { 69 | // 有需要立即开始学习的 70 | // DingTalkId的列表 71 | for _, dingId := range result.StartStudy { 72 | dingtalkId := dingId 73 | user, err := service.UserService.FindByDingtalkId(dingtalkId) 74 | if err != nil { 75 | logger.Errorln(err) 76 | continue 77 | } 78 | if user != nil { 79 | _ = ants.Submit(func() { 80 | study.StartStudyRightNow(user) 81 | }) 82 | } 83 | } 84 | } 85 | 86 | if len(result.TagUsers) > 0 { 87 | // 进行用户标记 88 | for _, dingId := range result.StartStudy { 89 | dingtalkId := dingId 90 | user, err := service.UserService.FindByDingtalkId(dingtalkId) 91 | if err != nil { 92 | logger.Errorln(err) 93 | continue 94 | } 95 | if user != nil { 96 | _ = ants.Submit(func() { 97 | _ = service.UserService.UpdateNotZero(context.Background(), &model.User{Id: user.Id, OnlyLoginTag: 1}) 98 | }) 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/job/lan/ResetScoreJob.go: -------------------------------------------------------------------------------- 1 | package lan 2 | 3 | import ( 4 | "context" 5 | 6 | "gitee.com/chunanyong/zorm" 7 | "github.com/yockii/qscore/pkg/task" 8 | 9 | "xxqg-automate/internal/model" 10 | ) 11 | 12 | func init() { 13 | task.AddFunc("@daily", func() { 14 | zorm.Transaction(context.Background(), func(ctx context.Context) (interface{}, error) { 15 | return zorm.UpdateFinder(ctx, zorm.NewUpdateFinder(model.UserTableName).Append("last_score=0")) 16 | }) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /internal/job/wan/AccessTokenJob.go: -------------------------------------------------------------------------------- 1 | package wan 2 | 3 | import ( 4 | logger "github.com/sirupsen/logrus" 5 | "github.com/yockii/qscore/pkg/config" 6 | "github.com/yockii/qscore/pkg/task" 7 | 8 | "xxqg-automate/internal/cache" 9 | "xxqg-automate/internal/constant" 10 | "xxqg-automate/internal/util" 11 | ) 12 | 13 | type atReq struct { 14 | AppKey string `json:"appKey,omitempty"` 15 | AppSecret string `json:"appSecret,omitempty"` 16 | } 17 | 18 | type atResp struct { 19 | AccessToken string `json:"accessToken,omitempty"` 20 | ExpireIn int64 `json:"expireIn,omitempty"` 21 | } 22 | 23 | func init() { 24 | task.AddFunc("@every 1h", func() { RefreshAccessToken() }) 25 | RefreshAccessToken() 26 | } 27 | 28 | func RefreshAccessToken() string { 29 | resp := new(atResp) 30 | response, err := util.GetClient().R().SetBody(&atReq{ 31 | AppKey: config.GetString("dingtalk.appKey"), 32 | AppSecret: config.GetString("dingtalk.appSecret"), 33 | }).SetResult(resp).Post(constant.DingtalkApiBaseUrl + "/v1.0/oauth2/accessToken") 34 | if err != nil { 35 | logger.Errorln(err) 36 | return "" 37 | } 38 | logger.Debugln(response.String()) 39 | cache.DefaultCache.Set(constant.DingtalkAccessToken, resp.AccessToken, resp.ExpireIn) 40 | return resp.AccessToken 41 | } 42 | -------------------------------------------------------------------------------- /internal/model/Job.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "gitee.com/chunanyong/zorm" 5 | "github.com/yockii/qscore/pkg/domain" 6 | ) 7 | 8 | const JobTableName = "t_job" 9 | 10 | type Job struct { 11 | zorm.EntityStruct 12 | Id string `json:"id,omitempty" column:"id"` 13 | UserId string `json:"userId,omitempty" column:"user_id"` 14 | Status int `json:"status,omitempty" column:"status"` // 1-学习任务 2-登录任务 15 | Score int `json:"score,omitempty" column:"score"` 16 | Code string `json:"code,omitempty" column:"code"` 17 | CreateTime domain.DateTime `json:"createTime" column:"create_time"` 18 | } 19 | 20 | func (entity *Job) GetTableName() string { 21 | return JobTableName 22 | } 23 | 24 | func (entity *Job) GetPKColumnName() string { 25 | return "id" 26 | } 27 | -------------------------------------------------------------------------------- /internal/model/User.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "gitee.com/chunanyong/zorm" 5 | "github.com/yockii/qscore/pkg/domain" 6 | ) 7 | 8 | const UserTableName = "t_user" 9 | 10 | type User struct { 11 | zorm.EntityStruct 12 | Id string `json:"id,omitempty" column:"id"` 13 | Nick string `json:"nick,omitempty" column:"nick"` 14 | Uid string `json:"uid,omitempty" column:"uid"` 15 | Token string `json:"token,omitempty" column:"token"` 16 | LoginTime int64 `json:"loginTime,omitempty" column:"login_time"` 17 | LastCheckTime domain.DateTime `json:"lastCheckTime" column:"last_check_time"` 18 | LastStudyTime domain.DateTime `json:"lastStudyTime" column:"last_study_time"` 19 | LastFinishTime domain.DateTime `json:"lastFinishTime" column:"last_finish_time"` 20 | LastScore int `json:"lastScore" column:"last_score"` 21 | Score int `json:"score" column:"score"` 22 | Status int `json:"status,omitempty" column:"status"` 23 | DingtalkId string `json:"dingtalkId,omitempty" column:"dingtalk_id"` 24 | OnlyLoginTag int `json:"onlyLoginTag,omitempty" column:"only_login_tag"` 25 | CreateTime domain.DateTime `json:"createTime" column:"create_time"` 26 | } 27 | 28 | func (entity *User) GetTableName() string { 29 | return UserTableName 30 | } 31 | 32 | func (entity *User) GetPKColumnName() string { 33 | return "id" 34 | } 35 | -------------------------------------------------------------------------------- /internal/service/DB.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "gitee.com/chunanyong/zorm" 5 | logger "github.com/sirupsen/logrus" 6 | "github.com/yockii/qscore/pkg/database" 7 | 8 | "xxqg-automate/internal/constant" 9 | ) 10 | 11 | func GetQuestionBankDb() *zorm.DBDao { 12 | dao, ok := database.DbMap[constant.QuestionBankSourceName] 13 | if !ok { 14 | dao, ok = database.DbMap[constant.QuestionBankSourceName] 15 | if !ok { 16 | logger.Errorln("无法成功获取题库数据库连接") 17 | return nil 18 | } 19 | } 20 | return dao 21 | } 22 | -------------------------------------------------------------------------------- /internal/service/JobService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "gitee.com/chunanyong/zorm" 10 | logger "github.com/sirupsen/logrus" 11 | "github.com/yockii/qscore/pkg/domain" 12 | 13 | "xxqg-automate/internal/model" 14 | ) 15 | 16 | var JobService = new(jobService) 17 | 18 | type jobService struct{} 19 | 20 | func (_ *jobService) Save(ctx context.Context, job *model.Job) error { 21 | // activity对象指针不能为空 22 | if job == nil { 23 | return errors.New("job对象指针不能为空") 24 | } 25 | 26 | job.CreateTime = domain.DateTime(time.Now()) 27 | 28 | //匿名函数return的error如果不为nil,事务就会回滚 29 | _, errSaveJob := zorm.Transaction(ctx, func(ctx context.Context) (interface{}, error) { 30 | 31 | //事务下的业务代码开始 32 | 33 | _, errSaveJob := zorm.Insert(ctx, job) 34 | 35 | if errSaveJob != nil { 36 | return nil, errSaveJob 37 | } 38 | 39 | return nil, nil 40 | //事务下的业务代码结束 41 | 42 | }) 43 | 44 | //记录错误 45 | if errSaveJob != nil { 46 | errSaveJob = fmt.Errorf("jobService.Save错误:%w", errSaveJob) 47 | logger.Error(errSaveJob) 48 | return errSaveJob 49 | } 50 | 51 | return nil 52 | } 53 | func (_ *jobService) DeleteById(ctx context.Context, id string) error { 54 | //id不能为空 55 | if len(id) < 1 { 56 | return errors.New("id不能为空") 57 | } 58 | 59 | //匿名函数return的error如果不为nil,事务就会回滚 60 | _, errDeleteActivity := zorm.Transaction(ctx, func(ctx context.Context) (interface{}, error) { 61 | 62 | //事务下的业务代码开始 63 | finder := zorm.NewDeleteFinder(model.JobTableName).Append(" WHERE id=?", id) 64 | _, errDeleteActivity := zorm.UpdateFinder(ctx, finder) 65 | 66 | if errDeleteActivity != nil { 67 | return nil, errDeleteActivity 68 | } 69 | 70 | return nil, nil 71 | //事务下的业务代码结束 72 | 73 | }) 74 | 75 | //记录错误 76 | if errDeleteActivity != nil { 77 | errDeleteActivity = fmt.Errorf("service.Delete错误:%w", errDeleteActivity) 78 | logger.Error(errDeleteActivity) 79 | return errDeleteActivity 80 | } 81 | 82 | return nil 83 | } 84 | func (_ *jobService) FindList(ctx context.Context, finder *zorm.Finder, page *zorm.Page) ([]*model.Job, error) { 85 | //finder不能为空 86 | if finder == nil { 87 | return nil, errors.New("finder不能为空") 88 | } 89 | 90 | jobList := make([]*model.Job, 0) 91 | errFindJobList := zorm.Query(ctx, finder, &jobList, page) 92 | 93 | //记录错误 94 | if errFindJobList != nil { 95 | errFindJobList = fmt.Errorf("jobService.FindList错误:%w", errFindJobList) 96 | logger.Error(errFindJobList) 97 | return nil, errFindJobList 98 | } 99 | 100 | return jobList, nil 101 | } 102 | 103 | func (_ *jobService) DeleteByUserId(ctx context.Context, userId string, status ...int) error { 104 | //id不能为空 105 | if len(userId) < 1 { 106 | return errors.New("id不能为空") 107 | } 108 | 109 | //匿名函数return的error如果不为nil,事务就会回滚 110 | _, errDeleteActivity := zorm.Transaction(ctx, func(ctx context.Context) (interface{}, error) { 111 | 112 | //事务下的业务代码开始 113 | finder := zorm.NewDeleteFinder(model.JobTableName).Append(" WHERE user_id=? and status in (?)", userId, status) 114 | _, errDeleteActivity := zorm.UpdateFinder(ctx, finder) 115 | 116 | if errDeleteActivity != nil { 117 | return nil, errDeleteActivity 118 | } 119 | 120 | return nil, nil 121 | //事务下的业务代码结束 122 | 123 | }) 124 | 125 | //记录错误 126 | if errDeleteActivity != nil { 127 | errDeleteActivity = fmt.Errorf("service.Delete错误:%w", errDeleteActivity) 128 | logger.Error(errDeleteActivity) 129 | return errDeleteActivity 130 | } 131 | 132 | return nil 133 | } 134 | -------------------------------------------------------------------------------- /internal/service/QuestionBankService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "gitee.com/chunanyong/zorm" 7 | logger "github.com/sirupsen/logrus" 8 | ) 9 | 10 | var QuestionBankService = new(questionBankService) 11 | 12 | type questionBankService struct{} 13 | 14 | func (s *questionBankService) SearchAnswer(question string) string { 15 | db := GetQuestionBankDb() 16 | if db == nil { 17 | return "" 18 | } 19 | ctx := context.Background() 20 | db.BindContextDBConnection(ctx) 21 | 22 | var answer string 23 | has, err := zorm.QueryRow( 24 | ctx, 25 | zorm.NewSelectFinder("tiku", "answer").Append("where question like ?", question+"%"), 26 | &answer, 27 | ) 28 | if err != nil { 29 | logger.Errorln(err) 30 | return "" 31 | } 32 | if !has || answer == "" { 33 | has, err = zorm.QueryRow( 34 | ctx, 35 | zorm.NewSelectFinder("tikuNet", "answer").Append("where question like ?", question+"%"), 36 | &answer, 37 | ) 38 | if err != nil { 39 | logger.Errorln(err) 40 | return "" 41 | } 42 | } 43 | return answer 44 | } 45 | -------------------------------------------------------------------------------- /internal/service/StatisticsService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "gitee.com/chunanyong/zorm" 8 | "github.com/sirupsen/logrus" 9 | 10 | "xxqg-automate/internal/domain" 11 | "xxqg-automate/internal/model" 12 | ) 13 | 14 | func GetStatisticsInfo() *domain.StatisticsInfo { 15 | var finished []*model.User 16 | finder := zorm.NewSelectFinder(model.UserTableName).Append("WHERE last_finish_time>? and last_score>0", time.Now().Format("2006-01-02")) 17 | err := zorm.Query(context.Background(), finder, &finished, nil) 18 | if err != nil { 19 | logrus.Error(err) 20 | } 21 | 22 | var studying []*model.User 23 | finder = zorm.NewSelectFinder(model.UserTableName). 24 | Append("WHERE id in (SELECT user_id FROM "+model.JobTableName+") and status=1 and last_study_time>? and last_study_time0 and (last_study_time?)", time.Now().Format("2006-01-02"), time.Now()) 40 | err = zorm.Query(context.Background(), finder, &waiting, nil) 41 | if err != nil { 42 | logrus.Error(err) 43 | } 44 | 45 | var notFinished []*model.User 46 | finder = zorm.NewSelectFinder(model.UserTableName).Append("WHERE last_score=0") 47 | err = zorm.Query(context.Background(), finder, ¬Finished, nil) 48 | if err != nil { 49 | logrus.Error(err) 50 | } 51 | 52 | info := new(domain.StatisticsInfo) 53 | for _, u := range finished { 54 | info.Finished = append(info.Finished, u.Nick) 55 | } 56 | for _, u := range studying { 57 | info.Studying = append(info.Studying, u.Nick) 58 | } 59 | for _, u := range expired { 60 | info.Expired = append(info.Expired, u.Nick) 61 | } 62 | for _, u := range waiting { 63 | info.Waiting = append(info.Waiting, u.Nick) 64 | } 65 | for _, u := range notFinished { 66 | info.NotFinished = append(info.NotFinished, u.Nick) 67 | } 68 | return info 69 | } 70 | -------------------------------------------------------------------------------- /internal/service/UserService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "gitee.com/chunanyong/zorm" 9 | logger "github.com/sirupsen/logrus" 10 | "github.com/yockii/qscore/pkg/config" 11 | 12 | "xxqg-automate/internal/constant" 13 | internalDomain "xxqg-automate/internal/domain" 14 | "xxqg-automate/internal/model" 15 | "xxqg-automate/internal/util" 16 | ) 17 | 18 | var UserService = new(userService) 19 | 20 | type userService struct{} 21 | 22 | func (s *userService) UpdateNotZero(ctx context.Context, user *model.User) error { 23 | // manager对象指针或主键Id不能为空 24 | if user == nil || len(user.Id) < 1 { 25 | return errors.New("user对象指针或主键Id不能为空") 26 | } 27 | 28 | //匿名函数return的error如果不为nil,事务就会回滚 29 | _, errUpdateUser := zorm.Transaction(ctx, func(ctx context.Context) (interface{}, error) { 30 | 31 | //事务下的业务代码开始 32 | _, errUpdateUser := zorm.UpdateNotZeroValue(ctx, user) 33 | 34 | if errUpdateUser != nil { 35 | return nil, errUpdateUser 36 | } 37 | 38 | return nil, nil 39 | //事务下的业务代码结束 40 | 41 | }) 42 | 43 | //记录错误 44 | if errUpdateUser != nil { 45 | errUpdateUser = fmt.Errorf("更新用户非空值错误:%w", errUpdateUser) 46 | logger.Error(errUpdateUser) 47 | return errUpdateUser 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func (_ *userService) FindByUsername(ctx context.Context, username string) (*model.User, error) { 54 | if username == "" { 55 | return nil, errors.New("用户名不能为空") 56 | } 57 | finder := zorm.NewSelectFinder(model.UserTableName).Append(" WHERE username=?", username) 58 | user := model.User{} 59 | has, errFindUserByUsername := zorm.QueryRow(ctx, finder, &user) 60 | 61 | // 记录错误 62 | if errFindUserByUsername != nil { 63 | errFindUserByUsername = fmt.Errorf("service.FindByUsername错误:%w", errFindUserByUsername) 64 | logger.Error(errFindUserByUsername) 65 | return nil, errFindUserByUsername 66 | } 67 | 68 | if !has { 69 | return nil, nil 70 | } 71 | return &user, nil 72 | } 73 | 74 | func (s *userService) GetById(ctx context.Context, id string) (*model.User, error) { 75 | if id == "" { 76 | return nil, errors.New("id不能为空") 77 | } 78 | finder := zorm.NewSelectFinder(model.UserTableName).Append("WHERE id=?", id) 79 | user := new(model.User) 80 | has, err := zorm.QueryRow(ctx, finder, user) 81 | if err != nil { 82 | logger.Error(err) 83 | return nil, err 84 | } 85 | if has { 86 | return user, nil 87 | } 88 | return nil, nil 89 | } 90 | 91 | func (s *userService) UpdateByUid(ctx context.Context, user *model.User) { 92 | finder := zorm.NewSelectFinder(model.UserTableName, "count(*)").Append("WHERE uid=?", user.Uid) 93 | var c int64 94 | if has, err := zorm.QueryRow(ctx, finder, &c); err != nil { 95 | logger.Error(err) 96 | return 97 | } else if !has { 98 | return 99 | } 100 | _, err := zorm.Transaction(ctx, func(ctx context.Context) (interface{}, error) { 101 | if c == 0 { 102 | // 新增 103 | user.Status = 1 104 | return zorm.Insert(ctx, user) 105 | } else { 106 | finder = zorm.NewUpdateFinder(model.UserTableName). 107 | Append("token=?, login_time=?, dingtalk_id=?, status=1", user.Token, user.LoginTime, user.DingtalkId).Append("WHERE uid=?", user.Uid) 108 | return zorm.UpdateFinder(ctx, finder) 109 | } 110 | }) 111 | if err != nil { 112 | logger.Errorln(err) 113 | return 114 | } 115 | if config.GetString("communicate.baseUrl") != "" { 116 | util.GetClient().R(). 117 | SetHeader("token", constant.CommunicateHeaderKey). 118 | SetBody(&internalDomain.NotifyInfo{ 119 | Nick: user.Nick, 120 | }).Post(config.GetString("communicate.baseUrl") + "/api/v1/loginSuccessNotify") 121 | } 122 | } 123 | 124 | func (s *userService) List(ctx context.Context) (users []*model.User, err error) { 125 | err = zorm.Query( 126 | ctx, 127 | zorm.NewSelectFinder(model.UserTableName), 128 | &users, 129 | nil, 130 | ) 131 | return 132 | } 133 | 134 | func (s *userService) BindUser(nick string, dingtalkId string) { 135 | success := false 136 | _, err := zorm.Transaction(context.Background(), func(ctx context.Context) (interface{}, error) { 137 | count, err := zorm.UpdateFinder( 138 | ctx, 139 | zorm.NewUpdateFinder(model.UserTableName).Append("dingtalk_id=?", dingtalkId).Append("WHERE nick=?", nick), 140 | ) 141 | success = count > 0 142 | return success, err 143 | }) 144 | if err != nil { 145 | logger.Errorln(err) 146 | return 147 | } 148 | if success && config.GetString("communicate.baseUrl") != "" { 149 | util.GetClient().R(). 150 | SetHeader("token", constant.CommunicateHeaderKey). 151 | SetBody(&internalDomain.NotifyInfo{ 152 | Nick: nick, 153 | Success: success, 154 | }).Post(config.GetString("communicate.baseUrl") + "/api/v1/bindSuccessNotify") 155 | } 156 | } 157 | 158 | func (s *userService) FindByDingtalkId(dingtalkId string) (*model.User, error) { 159 | if dingtalkId == "" { 160 | return nil, errors.New("钉钉ID不能为空") 161 | } 162 | finder := zorm.NewSelectFinder(model.UserTableName).Append(" WHERE dingtalk_id=?", dingtalkId) 163 | user := model.User{} 164 | has, errFindUserByUsername := zorm.QueryRow(context.Background(), finder, &user) 165 | 166 | // 记录错误 167 | if errFindUserByUsername != nil { 168 | errFindUserByUsername = fmt.Errorf("service.FindByUsername错误:%w", errFindUserByUsername) 169 | logger.Error(errFindUserByUsername) 170 | return nil, errFindUserByUsername 171 | } 172 | 173 | if !has { 174 | return nil, nil 175 | } 176 | return &user, nil 177 | } 178 | -------------------------------------------------------------------------------- /internal/study/Answer.go: -------------------------------------------------------------------------------- 1 | package study 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "math/rand" 9 | "net/http" 10 | "os" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/imroc/req/v3" 17 | "github.com/panjf2000/ants/v2" 18 | "github.com/playwright-community/playwright-go" 19 | logger "github.com/sirupsen/logrus" 20 | "github.com/tidwall/gjson" 21 | 22 | "xxqg-automate/internal/constant" 23 | "xxqg-automate/internal/model" 24 | "xxqg-automate/internal/service" 25 | ) 26 | 27 | const ( 28 | ButtonDaily = `#app > div > div.layout-body > div > 29 | div.my-points-section > div.my-points-content > div:nth-child(5) > div.my-points-card-footer > div.buttonbox > div` 30 | ) 31 | 32 | // Score 获取积分 33 | func (c *core) Score(user *model.User) (score *Score) { 34 | defer func() { 35 | if err := recover(); err != nil { 36 | logger.Errorf("%s获取积分异常! %s", user.Nick, err) 37 | } 38 | }() 39 | 40 | if !c.browser.IsConnected() { 41 | return 42 | } 43 | bc, err := c.browser.NewContext() 44 | if err != nil || bc == nil { 45 | logger.Errorf("%s创建浏览实例出错! %s", user.Nick, err) 46 | //TODO 退出系统重启 47 | os.Exit(1) 48 | return 49 | } 50 | // 添加一个script,防止被检测 51 | err = bc.AddInitScript(playwright.BrowserContextAddInitScriptOptions{ 52 | Script: playwright.String("Object.defineProperties(navigator, {webdriver:{get:()=>undefined}});")}) 53 | if err != nil { 54 | logger.Errorf("%s附加脚本出错! %s", user.Nick, err) 55 | return 56 | } 57 | defer func() { 58 | if err = bc.Close(); err != nil { 59 | logger.Errorf("%s关闭浏览实例出错: %s", user.Nick, err) 60 | } 61 | }() 62 | page, err := bc.NewPage() 63 | if err != nil || page == nil { 64 | logger.Errorf("%s 创建页面失败: %s", user.Nick, err) 65 | //TODO 退出系统重启 66 | os.Exit(1) 67 | return 68 | } 69 | defer func() { 70 | _ = page.Close() 71 | }() 72 | err = bc.AddCookies(ToBrowserCookies(user.Token)...) 73 | if err != nil { 74 | logger.Errorf("%s添加cookies失败: %s", user.Nick, err) 75 | return 76 | } 77 | // 跳转到积分页面 78 | _, err = page.Goto(constant.XxqgUrlMyPoints, playwright.PageGotoOptions{ 79 | Referer: playwright.String(constant.XxqgUrlMyPoints), 80 | Timeout: playwright.Float(10000), 81 | WaitUntil: playwright.WaitUntilStateDomcontentloaded, 82 | }) 83 | if err != nil { 84 | logger.Errorf("%s跳转页面失败: %s", user.Nick, err) 85 | return 86 | } 87 | time.Sleep(3 * time.Second) 88 | // 查找总积分 89 | total := c.getScore(page, "span.my-points-points.my-points-red", 0) 90 | today := c.getScore(page, "span.my-points-points", 1) 91 | // article 92 | login := c.getScore(page, "div.my-points-card-text", 0) 93 | article := c.getScore(page, "div.my-points-card-text", 1) 94 | video := c.getScore(page, "div.my-points-card-text", 2) 95 | videoTime := c.getScore(page, "div.my-points-card-text", 3) 96 | special := c.getScore(page, "div.my-points-card-text", 4) 97 | daily := c.getScore(page, "div.my-points-card-text", 5) 98 | score = &Score{ 99 | TotalScore: total, 100 | TodayScore: today, 101 | Content: map[string]*Data{ 102 | "login": { 103 | CurrentScore: login, 104 | MaxScore: 1, 105 | }, 106 | constant.Article: { 107 | CurrentScore: article, 108 | MaxScore: 12, 109 | }, 110 | constant.Video: { 111 | CurrentScore: video, 112 | MaxScore: 6, 113 | }, 114 | "video_time": { 115 | CurrentScore: videoTime, 116 | MaxScore: 6, 117 | }, 118 | "special": { 119 | CurrentScore: special, 120 | MaxScore: 10, 121 | }, 122 | "daily": { 123 | CurrentScore: daily, 124 | MaxScore: 5, 125 | }, 126 | }, 127 | } 128 | logger.Debugf("用户%s分数信息: %+v", user.Nick, score) 129 | return 130 | } 131 | 132 | func (c *core) getScore(page playwright.Page, selector string, order int) (score int) { 133 | divs, err := page.QuerySelectorAll(selector) 134 | if err != nil { 135 | logger.Debugf("获取元素失败: %s", err) 136 | return 137 | } 138 | if divs == nil { 139 | return 140 | } 141 | if len(divs) <= order { 142 | return 143 | } 144 | div := divs[order] 145 | //div, err := page.QuerySelector(selector) 146 | //if err != nil { 147 | // logger.Debugln("获取积分失败", err.Error()) 148 | // return 149 | //} 150 | if div == nil { 151 | return 152 | } 153 | str, err := div.InnerText() 154 | if err != nil { 155 | logger.Debugf("获取积分失败: %s", err) 156 | return 157 | } 158 | if strings.Contains(str, "分") { 159 | str = str[:strings.Index(str, "分")] 160 | } 161 | if str == "" { 162 | return 163 | } 164 | 165 | score64, _ := strconv.ParseInt(str, 10, 64) 166 | score = int(score64) 167 | return 168 | } 169 | 170 | // Answer 答题 1-每日 2-每周 3-专项 171 | func (c *core) Answer(user *model.User, t int) (tokenFailed bool) { 172 | defer func() { 173 | if err := recover(); err != nil { 174 | logger.Errorf("%s答题发生异常恢复! %s", user.Nick, err) 175 | // 尝试重新启动 176 | ants.Submit(func() { 177 | c.Answer(user, t) 178 | }) 179 | } 180 | }() 181 | 182 | score := c.Score(user) 183 | if score == nil || score.TotalScore == 0 { 184 | logger.Warnf("%s积分获取失败,停止答题", user.Nick) 185 | return 186 | } 187 | 188 | if !c.browser.IsConnected() { 189 | return 190 | } 191 | 192 | bc, err := c.browser.NewContext() 193 | if err != nil || bc == nil { 194 | logger.Errorf("%s创建浏览实例出错! %s", user.Nick, err) 195 | //TODO 退出系统重启 196 | os.Exit(1) 197 | return 198 | } 199 | // 添加一个script,防止被检测 200 | err = bc.AddInitScript(playwright.BrowserContextAddInitScriptOptions{ 201 | Script: playwright.String("Object.defineProperties(navigator, {webdriver:{get:()=>undefined}});")}) 202 | if err != nil { 203 | logger.Errorf("%s附加脚本出错! %s", user.Nick, err) 204 | return 205 | } 206 | defer func() { 207 | if err = bc.Close(); err != nil { 208 | logger.Errorf("%s关闭浏览实例出错: %s", user.Nick, err) 209 | } 210 | }() 211 | 212 | page, err := bc.NewPage() 213 | if err != nil || page == nil { 214 | logger.Errorf("%s创建页面失败: %s", user.Nick, err) 215 | //TODO 退出系统重启 216 | os.Exit(1) 217 | return 218 | } 219 | defer func() { 220 | _ = page.Close() 221 | }() 222 | err = bc.AddCookies(ToBrowserCookies(user.Token)...) 223 | if err != nil { 224 | logger.Errorf("%s添加cookies失败: %s", user.Nick, err) 225 | return 226 | } 227 | // 跳转到积分页面 228 | _, err = page.Goto(constant.XxqgUrlMyPoints, playwright.PageGotoOptions{ 229 | Referer: playwright.String(constant.XxqgUrlMyPoints), 230 | Timeout: playwright.Float(10000), 231 | WaitUntil: playwright.WaitUntilStateDomcontentloaded, 232 | }) 233 | if err != nil { 234 | logger.Errorf("%s跳转页面失败: %s", user.Nick, err.Error()) 235 | return 236 | } 237 | 238 | switch t { 239 | case 1: 240 | // 每日答题 241 | { 242 | if score.Content["daily"].CurrentScore >= score.Content["daily"].MaxScore { 243 | logger.Debugf("%s检测到每日答题已完成,退出每日答题", user.Nick) 244 | return 245 | } 246 | err = page.Click(ButtonDaily) 247 | if err != nil { 248 | logger.Errorf("%s跳转每日答题出错: %s", user.Nick, err) 249 | return 250 | } 251 | } 252 | case 2: 253 | // 每周答题 254 | { 255 | //if score.Content["weekly"].CurrentScore >= score.Content["weekly"].MaxScore { 256 | // logger.Debugln("检测到每周答题已完成,退出每周答题") 257 | // return 258 | //} 259 | //var id int 260 | //id, err = getWeekId(TokenToCookies(user.Token)) 261 | //if err != nil { 262 | // logger.Errorln("获取要答的每周答题出错", err) 263 | // return 264 | //} 265 | //if id == 0 { 266 | // logger.Warnln("未获取到每周答题id,退出答题") 267 | // return 268 | //} 269 | //_, err = page.Goto(fmt.Sprintf(constant.XxqgUrlWeekAnswerPage, id), playwright.PageGotoOptions{ 270 | // Referer: playwright.String(constant.XxqgUrlMyPoints), 271 | // Timeout: playwright.Float(10000), 272 | // WaitUntil: playwright.WaitUntilStateDomcontentloaded, 273 | //}) 274 | //if err != nil { 275 | // logger.Errorln("跳转每周答题出错", err) 276 | // return 277 | //} 278 | } 279 | case 3: 280 | // 专项 281 | { 282 | if score.Content["special"].CurrentScore >= score.Content["special"].MaxScore { 283 | logger.Debugf("%s检测到专项答题已经完成,退出答题", user.Nick) 284 | return 285 | } 286 | var id int 287 | id, err = getSpecialId(TokenToCookies(user.Token)) 288 | if err != nil { 289 | logger.Errorf("%s获取要答的专项答题出错: %s", user.Nick, err) 290 | return 291 | } 292 | if id == 0 { 293 | logger.Warnf("%s未获取到专项答题id,退出答题", user.Nick) 294 | return 295 | } 296 | _, err = page.Goto(fmt.Sprintf(constant.XxqgUrlSpecialAnswerPage, id), playwright.PageGotoOptions{ 297 | Referer: playwright.String(constant.XxqgUrlMyPoints), 298 | Timeout: playwright.Float(10000), 299 | WaitUntil: playwright.WaitUntilStateDomcontentloaded, 300 | }) 301 | if err != nil { 302 | logger.Errorf("%s跳转专项答题出错: %s", user.Nick, err) 303 | return 304 | } 305 | } 306 | } 307 | 308 | // 进入答题页面 309 | return c.startAnswer(user, &page, score, t) 310 | } 311 | 312 | func (c *core) startAnswer(user *model.User, p *playwright.Page, score *Score, t int) (tokenFailed bool) { 313 | page := *p 314 | var title string 315 | for i := 0; i < 30; i++ { 316 | if !c.browser.IsConnected() { 317 | return 318 | } 319 | // 查看是否存在答题按钮,若按钮可用则重新提交答题 320 | btn, err := page.QuerySelector(`#app > div > div.layout-body > div > div.detail-body > div.action-row > button`) 321 | if err != nil { 322 | logger.Debugf("%s获取提交按钮失败,本次答题结束: %s", user.Nick, err.Error()) 323 | return 324 | } 325 | if btn != nil { 326 | enabled, err := btn.IsEnabled() 327 | if err != nil { 328 | logger.Errorln(err.Error()) 329 | continue 330 | } 331 | if enabled { 332 | err := btn.Click() 333 | if err != nil { 334 | logger.Errorf("%s提交答案失败", user.Nick) 335 | } 336 | } 337 | } 338 | // 该元素存在则说明出现了滑块 339 | handle, _ := page.QuerySelector("#nc_mask > div") 340 | if handle != nil { 341 | logger.Debugln(handle) 342 | var en bool 343 | en, err = handle.IsVisible() 344 | if err != nil { 345 | return 346 | } 347 | if en { 348 | page.Mouse().Move(496, 422) 349 | time.Sleep(1 * time.Second) 350 | page.Mouse().Down() 351 | 352 | page.Mouse().Move(772, 416, playwright.MouseMoveOptions{}) 353 | page.Mouse().Up() 354 | time.Sleep(10 * time.Second) 355 | logger.Debugf("%s可能存在滑块", user.Nick) 356 | en, err = handle.IsVisible() 357 | if err != nil { 358 | return 359 | } 360 | if en { 361 | page.Evaluate("__nc.reset()") 362 | continue 363 | } 364 | } 365 | } 366 | 367 | switch t { 368 | case 1: 369 | { 370 | // 检测是否已经完成 371 | if score.Content["daily"] != nil && score.Content["daily"].CurrentScore >= score.Content["daily"].MaxScore { 372 | logger.Debugf("%s 检测到每日答题已经完成,退出答题", user.Nick) 373 | return 374 | } 375 | } 376 | case 2: 377 | { 378 | // 检测是否已经完成 379 | if score.Content["weekly"] != nil && score.Content["weekly"].CurrentScore >= score.Content["weekly"].MaxScore { 380 | logger.Debugf("%s 检测到每周答题已经完成,退出答题", user.Nick) 381 | return 382 | } 383 | } 384 | case 3: 385 | { 386 | // 检测是否已经完成 387 | if score.Content["special"] != nil && score.Content["special"].CurrentScore >= score.Content["special"].MaxScore { 388 | logger.Debugf("%s 检测到专项答题已经完成,退出答题", user.Nick) 389 | return 390 | } 391 | } 392 | } 393 | 394 | // 获取题目类型 395 | category, err := page.QuerySelector( 396 | `#app > div > div.layout-body > div > div.detail-body > div.question > div.q-header`) 397 | if err != nil { 398 | logger.Errorf("%s没有找到题目元素: %s", user.Nick, err) 399 | return 400 | } 401 | if category != nil { 402 | _ = category.WaitForElementState(`visible`) 403 | time.Sleep(1 * time.Second) 404 | 405 | // 获取题目 406 | var question playwright.ElementHandle 407 | question, err = page.QuerySelector( 408 | `#app > div > div.layout-body > div > div.detail-body > div.question > div.q-body > div`) 409 | if err != nil { 410 | logger.Errorf("%s未找到题目问题元素", user.Nick) 411 | return 412 | } 413 | // 获取题目类型 414 | categoryText := "" 415 | categoryText, err = category.TextContent() 416 | if err != nil { 417 | logger.Errorf("%s获取题目元素失败: %s", user.Nick, err) 418 | return 419 | } 420 | logger.Debugf("%s ## 题目类型:%s", user.Nick, categoryText) 421 | 422 | // 获取题目的问题 423 | questionText := "" 424 | questionText, err = question.TextContent() 425 | if err != nil { 426 | logger.Errorf("%s获取题目问题失败: %s", user.Nick, err) 427 | return 428 | } 429 | 430 | logger.Debugf("%s## 题目:%s", user.Nick, questionText) 431 | if title == questionText { 432 | logger.Warningf("%s 可能已经卡住,正在重试,重试次数+1", user.Nick) 433 | continue 434 | } else { 435 | i = 0 436 | } 437 | title = questionText 438 | 439 | // 获取答题帮助 440 | var openTips playwright.ElementHandle 441 | openTips, err = page.QuerySelector( 442 | `#app > div > div.layout-body > div > div.detail-body > div.question > div.q-footer > span`) 443 | if err != nil || openTips == nil { 444 | logger.Errorf("%s未获取到题目提示信息", user.Nick) 445 | continue 446 | } 447 | logger.Debugf("%s开始尝试获取打开提示信息按钮", user.Nick) 448 | // 点击提示的按钮 449 | err = openTips.Click() 450 | if err != nil { 451 | logger.Errorf("%s点击打开提示信息按钮失败: %s", user.Nick, err) 452 | continue 453 | } 454 | logger.Debugf("%s 已打开提示信息", user.Nick) 455 | // 获取页面内容 456 | content := "" 457 | content, err = page.Content() 458 | if err != nil { 459 | logger.Errorf("%s 获取网页全体内容失败", user.Nick, err) 460 | continue 461 | } 462 | time.Sleep(time.Second * time.Duration(rand.Intn(3))) 463 | // 关闭提示信息 464 | err = openTips.Click() 465 | if err != nil { 466 | logger.Errorf("%s 点击打开提示信息按钮失败: %s", user.Nick, err) 467 | continue 468 | } 469 | // 从整个页面内容获取提示信息 470 | tips := getTips(content) 471 | //logger.Debugln("[提示信息]:", tips) 472 | 473 | if i > 4 { 474 | logger.Warningf("%s 重试次数太多,即将退出答题", user.Nick) 475 | //options, _ := getOptions(page) 476 | return 477 | } 478 | 479 | // 填空题 480 | switch { 481 | case strings.Contains(categoryText, "填空题"): 482 | // 填充填空题 483 | err = FillBlank(page, tips) 484 | if err != nil { 485 | logger.Errorf("%s填空题答题失败: %s", user.Nick, err) 486 | return 487 | } 488 | case strings.Contains(categoryText, "多选题"): 489 | //logger.Debugln("读取到多选题") 490 | var options []string 491 | options, err = getOptions(page) 492 | if err != nil { 493 | logger.Errorf("%s获取选项失败: %s", user.Nick, err) 494 | return 495 | } 496 | //logger.Debugln("获取到选项答案:", options) 497 | //logger.Debugln("[多选题选项]:", options) 498 | var answer []string 499 | 500 | for _, option := range options { 501 | for _, tip := range tips { 502 | if strings.Contains(strings.ReplaceAll(option, " ", ""), strings.ReplaceAll(tip, " ", "")) { 503 | answer = append(answer, option) 504 | } 505 | } 506 | } 507 | 508 | answer = RemoveRepByLoop(answer) 509 | 510 | if len(answer) < 1 { 511 | answer = append(answer, options...) 512 | //logger.Debugln("无法判断答案,自动选择ABCD") 513 | } 514 | //logger.Debugln("根据提示分别选择了", answer) 515 | // 多选题选择 516 | err = radioCheck(page, answer) 517 | if err != nil { 518 | return 519 | } 520 | case strings.Contains(categoryText, "单选题"): 521 | //logger.Debugln("读取到单选题") 522 | var options []string 523 | options, err = getOptions(page) 524 | if err != nil { 525 | logger.Errorln("获取选项失败", err) 526 | return 527 | } 528 | //logger.Debugln("获取到选项答案:", options) 529 | 530 | var answer []string 531 | 532 | if len(tips) > 1 { 533 | logger.Warningf("%s 检测到单选题出现多个提示信息,即将对提示信息进行合并", user.Nick) 534 | tip := strings.Join(tips, "") 535 | tips = []string{tip} 536 | } 537 | 538 | for _, option := range options { 539 | for _, tip := range tips { 540 | if strings.Contains(option, tip) { 541 | answer = append(answer, option) 542 | } 543 | } 544 | } 545 | if len(answer) < 1 { 546 | answer = append(answer, options[0]) 547 | //logger.Debugln("无法判断答案,自动选择A") 548 | } 549 | 550 | //logger.Debugln("根据提示分别选择了", answer) 551 | err = radioCheck(page, answer) 552 | if err != nil { 553 | return 554 | } 555 | } 556 | } 557 | 558 | score = c.Score(user) 559 | if score == nil || score.TotalScore == 0 { 560 | logger.Errorf("%s 获取分数失败, 停止答题", user.Nick) 561 | return 562 | } 563 | } 564 | return 565 | } 566 | 567 | // RemoveRepByLoop 通过两重循环过滤重复元素 568 | func RemoveRepByLoop(slc []string) []string { 569 | var result []string // 存放结果 570 | for i := range slc { 571 | flag := true 572 | for j := range result { 573 | if slc[i] == result[j] { 574 | flag = false // 存在重复元素,标识为false 575 | break 576 | } 577 | } 578 | if flag { // 标识为false,不添加进结果 579 | result = append(result, slc[i]) 580 | } 581 | } 582 | return result 583 | } 584 | 585 | func radioCheck(page playwright.Page, answer []string) error { 586 | radios, err := page.QuerySelectorAll(`.q-answer.choosable`) 587 | if err != nil { 588 | //logger.Errorln("获取选项失败") 589 | return err 590 | } 591 | //logger.Debugln("获取到", len(radios), "个按钮") 592 | for _, radio := range radios { 593 | textContent := "" 594 | textContent, err = radio.TextContent() 595 | if err != nil { 596 | //logger.Errorln("获取选项答案文本出现错误", err) 597 | return err 598 | } 599 | for _, s := range answer { 600 | if textContent == s { 601 | err = radio.Click() 602 | if err != nil { 603 | logger.Errorln("点击选项出现错误", err) 604 | return err 605 | } 606 | r := rand.Intn(2) 607 | time.Sleep(time.Duration(r) * time.Second) 608 | } 609 | } 610 | } 611 | r := rand.Intn(5) 612 | time.Sleep(time.Duration(r) * time.Second) 613 | checkNextBotton(page) 614 | return nil 615 | } 616 | 617 | func getOptions(page playwright.Page) ([]string, error) { 618 | handles, err := page.QuerySelectorAll(`.q-answer.choosable`) 619 | if err != nil { 620 | logger.Errorln("获取选项信息失败") 621 | return nil, err 622 | } 623 | var options []string 624 | for _, handle := range handles { 625 | content, err := handle.TextContent() 626 | if err != nil { 627 | return nil, err 628 | } 629 | options = append(options, content) 630 | } 631 | return options, err 632 | } 633 | 634 | func getTips(data string) []string { 635 | data = strings.ReplaceAll(data, " ", "") 636 | data = strings.ReplaceAll(data, "\n", "") 637 | compile := regexp.MustCompile(`(.*?)`) 638 | match := compile.FindAllStringSubmatch(data, -1) 639 | var tips []string 640 | for _, i := range match { 641 | // 新增判断提示信息为空的逻辑 642 | if i[1] != "" { 643 | tips = append(tips, i[1]) 644 | } 645 | } 646 | return tips 647 | } 648 | 649 | func FillBlank(page playwright.Page, tips []string) error { 650 | video := false 651 | var answer []string 652 | if len(tips) < 1 { 653 | logger.Warningln("检测到未获取到提示信息") 654 | video = true 655 | } 656 | if video { 657 | data1, err := page.QuerySelector("#app > div > div.layout-body > div > div.detail-body > div.question > div.q-body > div > span:nth-child(1)") 658 | if err != nil { 659 | logger.Errorln("获取题目前半段失败" + err.Error()) 660 | return err 661 | } 662 | data1Text, _ := data1.TextContent() 663 | logger.Debugln("题目前半段:=》" + data1Text) 664 | searchAnswer := service.QuestionBankService.SearchAnswer(data1Text) 665 | if searchAnswer != "" { 666 | answer = append(answer, searchAnswer) 667 | } else { 668 | answer = append(answer, "不知道") 669 | } 670 | } else { 671 | answer = tips 672 | } 673 | inouts, err := page.QuerySelectorAll(`div.q-body > div > input`) 674 | if err != nil { 675 | logger.Errorln("获取输入框错误" + err.Error()) 676 | return err 677 | } 678 | logger.Debugln("获取到", len(inouts), "个填空") 679 | if len(inouts) == 1 && len(tips) > 1 { 680 | temp := "" 681 | for _, tip := range tips { 682 | temp += tip 683 | } 684 | answer = strings.Split(temp, ",") 685 | logger.Debugln("答案已合并处理") 686 | } 687 | var ans string 688 | for i := 0; i < len(inouts); i++ { 689 | if len(answer) < i+1 { 690 | ans = "不知道" 691 | } else { 692 | ans = answer[i] 693 | } 694 | 695 | err := inouts[i].Fill(ans) 696 | if err != nil { 697 | logger.Errorln("填充答案失败" + err.Error()) 698 | continue 699 | } 700 | r := rand.Intn(4) + 1 701 | time.Sleep(time.Duration(r) * time.Second) 702 | } 703 | r := rand.Intn(1) + 1 704 | time.Sleep(time.Duration(r) * time.Second) 705 | checkNextBotton(page) 706 | return nil 707 | } 708 | 709 | func checkNextBotton(page playwright.Page) { 710 | btns, err := page.QuerySelectorAll(`#app .action-row > button`) 711 | if err != nil { 712 | logger.Errorln("未检测到按钮" + err.Error()) 713 | 714 | return 715 | } 716 | if len(btns) <= 1 { 717 | err := btns[0].Click() 718 | if err != nil { 719 | logger.Errorln("点击下一题按钮失败") 720 | 721 | return 722 | } 723 | time.Sleep(2 * time.Second) 724 | _, err = btns[0].GetAttribute("disabled") 725 | if err != nil { 726 | logger.Debugln("未检测到禁言属性") 727 | 728 | return 729 | } 730 | } else { 731 | err := btns[1].Click() 732 | if err != nil { 733 | logger.Errorln("提交试卷失败") 734 | 735 | return 736 | } 737 | logger.Debugln("已成功提交试卷") 738 | } 739 | } 740 | 741 | type SpecialList struct { 742 | PageNo int `json:"pageNo"` 743 | PageSize int `json:"pageSize"` 744 | TotalPageCount int `json:"totalPageCount"` 745 | TotalCount int `json:"totalCount"` 746 | List []struct { 747 | TipScore float64 `json:"tipScore"` 748 | EndDate string `json:"endDate"` 749 | Achievement struct { 750 | Score int `json:"score"` 751 | Total int `json:"total"` 752 | Correct int `json:"correct"` 753 | } `json:"achievement"` 754 | Year int `json:"year"` 755 | SeeSolution bool `json:"seeSolution"` 756 | Score int `json:"score"` 757 | ExamScoreId int `json:"examScoreId"` 758 | UsedTime int `json:"usedTime"` 759 | Overdue bool `json:"overdue"` 760 | Month int `json:"month"` 761 | Name string `json:"name"` 762 | QuestionNum int `json:"questionNum"` 763 | AlreadyAnswerNum int `json:"alreadyAnswerNum"` 764 | StartTime string `json:"startTime"` 765 | Id int `json:"id"` 766 | ExamTime int `json:"examTime"` 767 | Forever int `json:"forever"` 768 | StartDate string `json:"startDate"` 769 | Status int `json:"status"` 770 | } `json:"list"` 771 | PageNum int `json:"pageNum"` 772 | } 773 | 774 | func getSpecialId(cookies []*http.Cookie) (int, error) { 775 | c := req.C() 776 | c.SetCommonCookies(cookies...) 777 | // 获取专项答题列表 778 | repo, err := c.R().SetQueryParams(map[string]string{"pageSize": "1000", "pageNo": "1"}).Get(constant.XxqgUrlSpecialList) 779 | if err != nil { 780 | logger.Errorln("获取专项答题列表错误" + err.Error()) 781 | return 0, err 782 | } 783 | dataB64, err := repo.ToString() 784 | if err != nil { 785 | logger.Errorln("获取专项答题列表获取string错误" + err.Error()) 786 | return 0, err 787 | } 788 | // 因为返回内容使用base64编码,所以需要对内容进行转码 789 | data, err := base64.StdEncoding.DecodeString(gjson.Get(dataB64, "data_str").String()) 790 | if err != nil { 791 | logger.Errorln("获取专项答题列表转换b64错误" + err.Error()) 792 | return 0, err 793 | } 794 | // 创建实例对象 795 | list := new(SpecialList) 796 | // json序列号 797 | err = json.Unmarshal(data, list) 798 | if err != nil { 799 | logger.Errorln("获取专项答题列表转换json错误" + err.Error()) 800 | return 0, err 801 | } 802 | logger.Debugln(fmt.Sprintf("共获取到专项答题%d个", list.TotalCount)) 803 | 804 | // 判断是否配置选题顺序,若ReverseOrder为true则从后面选题 805 | //if conf.GetConfig().ReverseOrder { 806 | // for i := len(list.List) - 1; i >= 0; i-- { 807 | // if list.List[i].TipScore == 0 { 808 | // logger.Debugln(fmt.Sprintf("获取到未答专项答题: %v,id: %v", list.List[i].Name, list.List[i].Id)) 809 | // return list.List[i].Id, nil 810 | // } 811 | // } 812 | //} else { 813 | for _, s := range list.List { 814 | if s.TipScore == 0 { 815 | logger.Debugln(fmt.Sprintf("获取到未答专项答题: %v,id: %v", s.Name, s.Id)) 816 | return s.Id, nil 817 | } 818 | } 819 | //} 820 | logger.Warningln("你已不存在未答的专项答题了") 821 | return 0, errors.New("未找到专项答题") 822 | } 823 | 824 | type WeekList struct { 825 | PageNo int `json:"pageNo"` 826 | PageSize int `json:"pageSize"` 827 | TotalPageCount int `json:"totalPageCount"` 828 | TotalCount int `json:"totalCount"` 829 | List []struct { 830 | Month string `json:"month"` 831 | Practices []struct { 832 | SeeSolution bool `json:"seeSolution"` 833 | TipScore float64 `json:"tipScore"` 834 | ExamScoreId int `json:"examScoreId"` 835 | Overdue bool `json:"overdue"` 836 | Achievement struct { 837 | Total int `json:"total"` 838 | Correct int `json:"correct"` 839 | } `json:"achievement"` 840 | Name string `json:"name"` 841 | BeginYear int `json:"beginYear"` 842 | StartTime string `json:"startTime"` 843 | Id int `json:"id"` 844 | BeginMonth int `json:"beginMonth"` 845 | Status int `json:"status"` 846 | TipScoreReasonType int `json:"tipScoreReasonType"` 847 | } `json:"practices"` 848 | } `json:"list"` 849 | PageNum int `json:"pageNum"` 850 | } 851 | 852 | func getWeekId(cookies []*http.Cookie) (int, error) { 853 | c := req.C() 854 | c.SetCommonCookies(cookies...) 855 | repo, err := c.R().SetQueryParams(map[string]string{"pageSize": "500", "pageNo": "1"}).Get(constant.XxqgUrlWeekList) 856 | if err != nil { 857 | logger.Errorln("获取每周答题列表错误" + err.Error()) 858 | return 0, err 859 | } 860 | dataB64, err := repo.ToString() 861 | if err != nil { 862 | logger.Errorln("获取每周答题列表获取string错误" + err.Error()) 863 | return 0, err 864 | } 865 | data, err := base64.StdEncoding.DecodeString(gjson.Get(dataB64, "data_str").String()) 866 | if err != nil { 867 | logger.Errorln("获取每周答题列表转换b64错误" + err.Error()) 868 | return 0, err 869 | } 870 | list := new(WeekList) 871 | err = json.Unmarshal(data, list) 872 | if err != nil { 873 | logger.Errorln("获取每周答题列表转换json错误" + err.Error()) 874 | return 0, err 875 | } 876 | logger.Debugln(fmt.Sprintf("共获取到每周答题%d个", list.TotalCount)) 877 | 878 | //if conf.GetConfig().ReverseOrder { 879 | // for i := len(list.List) - 1; i >= 0; i-- { 880 | // for _, practice := range list.List[i].Practices { 881 | // if practice.TipScore == 0 { 882 | // logger.Debugln(fmt.Sprintf("获取到未答每周答题: %v,id: %v", practice.Name, practice.Id)) 883 | // return practice.Id, nil 884 | // } 885 | // } 886 | // } 887 | //} else { 888 | for _, s := range list.List { 889 | for _, practice := range s.Practices { 890 | if practice.TipScore == 0 { 891 | logger.Debugln(fmt.Sprintf("获取到未答每周答题: %v,id: %v", practice.Name, practice.Id)) 892 | return practice.Id, nil 893 | } 894 | } 895 | } 896 | //} 897 | logger.Warningln("你已不存在未答的每周答题了") 898 | return 0, errors.New("未找到每周答题") 899 | } 900 | -------------------------------------------------------------------------------- /internal/study/Article.go: -------------------------------------------------------------------------------- 1 | package study 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/playwright-community/playwright-go" 9 | logger "github.com/sirupsen/logrus" 10 | 11 | "xxqg-automate/internal/constant" 12 | "xxqg-automate/internal/model" 13 | ) 14 | 15 | func (c *core) Learn(user *model.User, learnModule string) (tokenFailed bool) { 16 | if !c.browser.IsConnected() { 17 | return 18 | } 19 | score := c.Score(user) 20 | if score == nil || score.TotalScore == 0 { 21 | logger.Warnf("未能成功获取到用户%s的积分,停止学习", user.Nick) 22 | return 23 | } 24 | if score == nil { 25 | logger.Debugf("未获取到分数,结束%s学习\n", learnModule) 26 | return 27 | } 28 | if learnModule == constant.Article { 29 | if articleScore, ok := score.Content[constant.Article]; !ok || articleScore.CurrentScore >= articleScore.MaxScore { 30 | logger.Debugf("%s 检测到文章学习已完成,结束学习", user.Nick) 31 | return 32 | } 33 | } else if learnModule == constant.Video { 34 | if videoScore, ok := score.Content[constant.Video]; !ok || (videoScore.CurrentScore >= videoScore.MaxScore && score.Content["video_time"].CurrentScore >= score.Content["video_time"].MaxScore) { 35 | logger.Debugf("%s 检测到视频学习已完成,结束学习", user.Nick) 36 | return 37 | } 38 | } 39 | 40 | bc, err := c.browser.NewContext(playwright.BrowserNewContextOptions{ 41 | Viewport: &playwright.BrowserNewContextOptionsViewport{ 42 | Width: playwright.Int(1920), 43 | Height: playwright.Int(1080), 44 | }, 45 | }) 46 | if err != nil { 47 | logger.Errorf("%s创建浏览实例出错! %s", user.Nick, err) 48 | return 49 | } 50 | err = bc.AddInitScript(playwright.BrowserContextAddInitScriptOptions{ 51 | Script: playwright.String("Object.defineProperties(navigator, {webdriver:{get:()=>undefined}});"), 52 | }) 53 | if err != nil { 54 | logger.Errorf("%s 初始化浏览实例出错! %s", user.Nick, err) 55 | return 56 | } 57 | defer func() { 58 | if err := bc.Close(); err != nil { 59 | logger.Errorf("%s关闭浏览实例出错! %s", user.Nick, err) 60 | } 61 | }() 62 | bc.AddCookies(ToBrowserCookies(user.Token)...) 63 | 64 | page, err := bc.NewPage() 65 | if err != nil { 66 | logger.Errorf("%s新建页面出错! %s", user.Nick, err) 67 | return 68 | } 69 | defer func() { 70 | if err := page.Close(); err != nil { 71 | logger.Errorf("%s关闭页面失败! %s", user.Nick, err) 72 | } 73 | }() 74 | switch learnModule { 75 | case constant.Article: 76 | c.startLearnArticle(user, &page, score) 77 | case constant.Video: 78 | c.startLearnVideo(user, &page, score) 79 | } 80 | return 81 | } 82 | 83 | func (c *core) startLearnArticle(user *model.User, p *playwright.Page, score *Score) (tokenFailed bool) { 84 | page := *p 85 | for i := 0; i < 20; i++ { 86 | links, _ := getLinks(constant.Article) 87 | if len(links) == 0 { 88 | continue 89 | } 90 | n := rand.Intn(len(links)) 91 | _, err := page.Goto(links[n].Url, playwright.PageGotoOptions{ 92 | Referer: playwright.String(links[rand.Intn(len(links))].Url), 93 | Timeout: playwright.Float(10000), 94 | WaitUntil: playwright.WaitUntilStateDomcontentloaded, 95 | }) 96 | if err != nil { 97 | logger.Errorf("%s页面跳转失败", user.Nick) 98 | continue 99 | } 100 | logger.Debugf("%s 正在学习文章: %s", user.Nick, links[n].Title) 101 | learnTime := 60 + rand.Intn(15) + 3 102 | for j := 0; j < learnTime; j++ { 103 | if !c.browser.IsConnected() { 104 | return 105 | } 106 | if rand.Float32() > 0.5 { 107 | go func() { 108 | _, err = page.Evaluate(fmt.Sprintf("let h = document.body.scrollHeight/120*%d;document.documentElement.scrollTop=h;", j)) 109 | if err != nil { 110 | logger.Errorf("%s 文章下滑失败", user.Nick) 111 | } 112 | }() 113 | } 114 | 115 | time.Sleep(((time.Duration)(200 + rand.Intn(800))) * time.Millisecond) 116 | } 117 | score = c.Score(user) 118 | if score == nil || score.TotalScore == 0 { 119 | logger.Warnf("未能成功获取到用户%s的积分,停止学习", user.Nick) 120 | return 121 | } 122 | if articleScore, ok := score.Content[constant.Article]; !ok || articleScore.CurrentScore >= articleScore.MaxScore { 123 | logger.Debugf("%s 检测到文章学习已完成,结束文章学习", user.Nick) 124 | return 125 | } 126 | } 127 | return 128 | } 129 | -------------------------------------------------------------------------------- /internal/study/BasicInfo.go: -------------------------------------------------------------------------------- 1 | package study 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/google/uuid" 7 | "github.com/prometheus/common/log" 8 | logger "github.com/sirupsen/logrus" 9 | "github.com/tidwall/gjson" 10 | 11 | "xxqg-automate/internal/constant" 12 | "xxqg-automate/internal/model" 13 | "xxqg-automate/internal/util" 14 | ) 15 | 16 | func CheckUserCookie(cookies []*http.Cookie) (bool, error) { 17 | client := util.GetClient() 18 | response, err := client.R().SetCookies(cookies...).Get("https://pc-api.xuexi.cn/open/api/score/get") 19 | if err != nil { 20 | log.Errorln("获取用户总分错误" + err.Error()) 21 | return true, err 22 | } 23 | if !gjson.GetBytes(response.Bytes(), "ok").Bool() { 24 | return false, nil 25 | } 26 | return true, nil 27 | } 28 | 29 | func GetToken(code, sign string) (*model.User, error) { 30 | resp, err := util.GetClient().R().SetQueryParams(map[string]string{ 31 | "code": code, 32 | "state": sign + uuid.New().String(), 33 | }).Get("https://pc-api.xuexi.cn/login/secure_check") 34 | if err != nil { 35 | logger.Errorln(err) 36 | return nil, err 37 | } 38 | user, err := GetUserInfo(resp.Cookies()) 39 | if err != nil { 40 | logger.Errorln(err) 41 | return nil, err 42 | } 43 | var token string 44 | 45 | for _, cookie := range resp.Cookies() { 46 | if cookie.Name == "token" { 47 | token = cookie.Value 48 | break 49 | } 50 | } 51 | user.Token = token 52 | return user, nil 53 | } 54 | 55 | func GetUserInfo(cookies []*http.Cookie) (*model.User, error) { 56 | response, err := util.GetClient().R().SetCookies(cookies...).SetHeader("Cache-Control", "no-cache").Get(constant.XxqgUrlUserInfo) 57 | if err != nil { 58 | logger.Errorln("获取用户信息失败" + err.Error()) 59 | return nil, err 60 | } 61 | resp := response.String() 62 | logger.Debugln("[user] 用户信息:", resp) 63 | j := gjson.Parse(resp) 64 | uid := j.Get("data.uid").String() 65 | nick := j.Get("data.nick").String() 66 | 67 | return &model.User{ 68 | Nick: nick, 69 | Uid: uid, 70 | }, err 71 | } 72 | -------------------------------------------------------------------------------- /internal/study/Video.go: -------------------------------------------------------------------------------- 1 | package study 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/panjf2000/ants/v2" 9 | "github.com/playwright-community/playwright-go" 10 | logger "github.com/sirupsen/logrus" 11 | 12 | "xxqg-automate/internal/constant" 13 | "xxqg-automate/internal/model" 14 | ) 15 | 16 | func (c *core) startLearnVideo(user *model.User, p *playwright.Page, score *Score) (tokenFailed bool) { 17 | page := *p 18 | for i := 0; i < 20; i++ { 19 | links, _ := getLinks(constant.Video) 20 | if len(links) == 0 { 21 | continue 22 | } 23 | 24 | if score.Content[constant.Video] != nil && score.Content[constant.Video].CurrentScore >= score.Content[constant.Video].MaxScore && score.Content["video_time"] != nil && score.Content["video_time"].CurrentScore >= score.Content["video_time"].MaxScore { 25 | logger.Debugf("%s 检测到视频学习已经完成", user.Nick) 26 | return 27 | } else { 28 | n := rand.Intn(len(links)) 29 | _, err := page.Goto(links[n].Url, playwright.PageGotoOptions{ 30 | Referer: playwright.String(links[rand.Intn(len(links))].Url), 31 | Timeout: playwright.Float(10000), 32 | WaitUntil: playwright.WaitUntilStateDomcontentloaded, 33 | }) 34 | if err != nil { 35 | logger.Errorf("%s页面跳转失败", user.Nick) 36 | continue 37 | } 38 | logger.Debugln("正在观看视频: ", links[n].Title) 39 | learnTime := 60 + rand.Intn(15) + 3 40 | for j := 0; j < learnTime; j++ { 41 | if !c.browser.IsConnected() { 42 | return 43 | } 44 | if rand.Float32() > 0.5 { 45 | ants.Submit(func() { 46 | _, err = page.Evaluate(fmt.Sprintf("let h = document.body.scrollHeight/120*%d;document.documentElement.scrollTop=h;", j)) 47 | if err != nil { 48 | logger.Errorf("%s视频下滑失败", user.Nick) 49 | } 50 | }) 51 | } 52 | time.Sleep(1 * time.Second) 53 | } 54 | score = c.Score(user) 55 | if score == nil || score.TotalScore == 0 { 56 | logger.Warnf("未能成功获取到用户%s的积分,停止学习", user.Nick) 57 | return 58 | } 59 | if score.Content[constant.Video] != nil && score.Content[constant.Video].CurrentScore >= score.Content[constant.Video].MaxScore && score.Content["video_time"] != nil && score.Content["video_time"].CurrentScore >= score.Content["video_time"].MaxScore { 60 | logger.Debugf("%s 检测到本次视频学习分数已满,退出学习", user.Nick) 61 | break 62 | } 63 | } 64 | } 65 | return 66 | } 67 | -------------------------------------------------------------------------------- /internal/study/XxqgService.go: -------------------------------------------------------------------------------- 1 | package study 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | "time" 9 | 10 | "gitee.com/chunanyong/zorm" 11 | "github.com/google/uuid" 12 | "github.com/panjf2000/ants/v2" 13 | logger "github.com/sirupsen/logrus" 14 | "github.com/yockii/qscore/pkg/config" 15 | 16 | "xxqg-automate/internal/model" 17 | "xxqg-automate/internal/service" 18 | "xxqg-automate/internal/util" 19 | ) 20 | 21 | func LoadLoginJobs() { 22 | jobs, err := service.JobService.FindList(context.Background(), 23 | zorm.NewSelectFinder(model.JobTableName).Append("WHERE status=2"), 24 | nil, 25 | ) 26 | if err != nil { 27 | logger.Errorln(err) 28 | return 29 | } 30 | for _, job := range jobs { 31 | ants.Submit(func() { 32 | checkLogin(job) 33 | service.JobService.DeleteById(context.Background(), job.Id) 34 | }) 35 | } 36 | } 37 | 38 | type checkQrCodeResp struct { 39 | Code string `json:"code"` 40 | Success bool `json:"success"` 41 | Message string `json:"message"` 42 | Data string `json:"data"` 43 | } 44 | 45 | func GetXxqgRedirectUrl(dingIds ...string) (ru string, err error) { 46 | dingId := "" 47 | if len(dingIds) > 0 { 48 | dingId = dingIds[0] 49 | } 50 | client := util.GetClient() 51 | type generateResp struct { 52 | Success bool `json:"success"` 53 | ErrorCode interface{} `json:"errorCode"` 54 | ErrorMsg interface{} `json:"errorMsg"` 55 | Result string `json:"result"` 56 | Arguments interface{} `json:"arguments"` 57 | } 58 | g := new(generateResp) 59 | _, err = client.R().SetResult(g).Get("https://login.xuexi.cn/user/qrcode/generate") 60 | if err != nil { 61 | logger.Errorln(err.Error()) 62 | return 63 | } 64 | logger.Infoln(g.Result) 65 | codeURL := fmt.Sprintf("https://login.xuexi.cn/login/qrcommit?showmenu=false&code=%v&appId=dingoankubyrfkttorhpou", g.Result) 66 | 67 | code := g.Result 68 | ants.Submit(func() { 69 | job := &model.Job{ 70 | Status: 2, 71 | Code: code + "|" + dingId, 72 | } 73 | service.JobService.Save(context.Background(), job) 74 | 75 | checkLogin(job) 76 | 77 | service.JobService.DeleteById(context.Background(), job.Id) 78 | }) 79 | ru = config.GetString("xxqg.schema") + url.QueryEscape(codeURL) 80 | return 81 | } 82 | 83 | func checkLogin(job *model.Job) { 84 | client := util.GetClient() 85 | codeWithDingId := strings.Split(job.Code, "|") 86 | code := codeWithDingId[0] 87 | dingId := codeWithDingId[1] 88 | for i := 0; i < 150; i++ { 89 | res := new(checkQrCodeResp) 90 | _, err := client.R().SetResult(res).SetFormData(map[string]string{ 91 | "qrCode": code, 92 | "goto": "https://oa.xuexi.cn", 93 | "pdmToken": "", 94 | }).SetHeader("content-type", "application/x-www-form-urlencoded;charset=UTF-8"). 95 | Post("https://login.xuexi.cn/login/login_with_qr") 96 | if err != nil { 97 | logger.Error(err) 98 | continue 99 | } 100 | if !res.Success { 101 | time.Sleep(500 * time.Millisecond) 102 | continue 103 | } 104 | 105 | type signResp struct { 106 | Data struct { 107 | Sign string `json:"sign"` 108 | } `json:"data"` 109 | Message string `json:"message"` 110 | Code int `json:"code"` 111 | Error interface{} `json:"error"` 112 | Ok bool `json:"ok"` 113 | } 114 | s := res.Data 115 | sign := new(signResp) 116 | _, err = client.R().SetResult(sign).Get("https://pc-api.xuexi.cn/open/api/sns/sign") 117 | if err != nil { 118 | logger.Errorln(err) 119 | return 120 | } 121 | s2 := strings.Split(s, "=")[1] 122 | response, err := client.R().SetQueryParams(map[string]string{ 123 | "code": s2, 124 | "state": sign.Data.Sign + uuid.New().String(), 125 | }).Get("https://pc-api.xuexi.cn/login/secure_check") 126 | if err != nil { 127 | logger.Errorln(err) 128 | return 129 | } 130 | user, err := GetUserInfo(response.Cookies()) 131 | if err != nil { 132 | logger.Errorln(err) 133 | return 134 | } 135 | // 登录成功 136 | user.Token = response.Cookies()[0].Value 137 | user.LoginTime = time.Now().Unix() 138 | user.DingtalkId = dingId 139 | service.UserService.UpdateByUid(context.Background(), user) 140 | return 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /internal/study/core.go: -------------------------------------------------------------------------------- 1 | package study 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "os" 7 | "runtime" 8 | "time" 9 | 10 | "gitee.com/chunanyong/zorm" 11 | "github.com/playwright-community/playwright-go" 12 | "github.com/prometheus/common/log" 13 | logger "github.com/sirupsen/logrus" 14 | "github.com/yockii/qscore/pkg/config" 15 | "github.com/yockii/qscore/pkg/domain" 16 | 17 | "xxqg-automate/internal/constant" 18 | internalDomain "xxqg-automate/internal/domain" 19 | "xxqg-automate/internal/model" 20 | "xxqg-automate/internal/service" 21 | "xxqg-automate/internal/util" 22 | ) 23 | 24 | var Core *core 25 | 26 | type core struct { 27 | pw *playwright.Playwright 28 | browser playwright.Browser 29 | ShowBrowser bool 30 | } 31 | 32 | func Init() { 33 | Core = new(core) 34 | if runtime.GOOS == "windows" { 35 | Core.initWindows() 36 | } else { 37 | Core.initNotWindows() 38 | } 39 | } 40 | 41 | func Quit() { 42 | err := Core.browser.Close() 43 | if err != nil { 44 | log.Errorln("关闭浏览器失败" + err.Error()) 45 | return 46 | } 47 | err = Core.pw.Stop() 48 | if err != nil { 49 | return 50 | } 51 | } 52 | 53 | func (c *core) initWindows() { 54 | path := "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" 55 | _, err := os.Stat(path) 56 | if err != nil { 57 | if os.IsNotExist(err) { 58 | logger.Warningln("检测到edge浏览器不存在,将自动下载chrome浏览器") 59 | c.initNotWindows() 60 | return 61 | } 62 | err = nil 63 | } 64 | 65 | dir, err := os.Getwd() 66 | if err != nil { 67 | return 68 | } 69 | 70 | pwo := &playwright.RunOptions{ 71 | DriverDirectory: dir + "/tools/driver/", 72 | SkipInstallBrowsers: true, 73 | Browsers: []string{"msedge"}, 74 | } 75 | 76 | err = playwright.Install(pwo) 77 | if err != nil { 78 | log.Errorln("[core]", "安装playwright失败") 79 | log.Errorln("[core] ", err.Error()) 80 | 81 | return 82 | } 83 | 84 | pwt, err := playwright.Run(pwo) 85 | if err != nil { 86 | log.Errorln("[core]", "初始化playwright失败") 87 | log.Errorln("[core] ", err.Error()) 88 | 89 | return 90 | } 91 | c.pw = pwt 92 | browser, err := pwt.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ 93 | Args: []string{ 94 | "--disable-extensions", 95 | "--disable-gpu", 96 | "--start-maximized", 97 | "--no-sandbox", 98 | "--window-size=500,450", 99 | "--mute-audio", 100 | "--window-position=0,0", 101 | "--ignore-certificate-errors", 102 | "--ignore-ssl-errors", 103 | "--disable-features=RendererCodeIntegrity", 104 | "--disable-blink-features", 105 | "--disable-blink-features=AutomationControlled", 106 | }, 107 | Channel: nil, 108 | ChromiumSandbox: nil, 109 | Devtools: nil, 110 | DownloadsPath: playwright.String("./tools/temp/"), 111 | ExecutablePath: playwright.String(path), 112 | HandleSIGHUP: nil, 113 | HandleSIGINT: nil, 114 | HandleSIGTERM: nil, 115 | Headless: playwright.Bool(!c.ShowBrowser), 116 | Proxy: nil, 117 | SlowMo: nil, 118 | Timeout: nil, 119 | }) 120 | if err != nil { 121 | log.Errorln("[core] ", "初始化edge失败") 122 | log.Errorln("[core] ", err.Error()) 123 | return 124 | } 125 | 126 | c.browser = browser 127 | } 128 | 129 | func (c *core) initNotWindows() { 130 | dir, err := os.Getwd() 131 | if err != nil { 132 | return 133 | } 134 | _, b := os.LookupEnv("PLAYWRIGHT_BROWSERS_PATH") 135 | if !b { 136 | err = os.Setenv("PLAYWRIGHT_BROWSERS_PATH", dir+"/tools/browser/") 137 | if err != nil { 138 | log.Errorln("设置环境变量PLAYWRIGHT_BROWSERS_PATH失败" + err.Error()) 139 | err = nil 140 | } 141 | } 142 | 143 | pwo := &playwright.RunOptions{ 144 | DriverDirectory: dir + "/tools/driver/", 145 | SkipInstallBrowsers: false, 146 | Browsers: []string{"chromium"}, 147 | } 148 | 149 | err = playwright.Install(pwo) 150 | if err != nil { 151 | log.Errorln("[core]", "安装playwright失败") 152 | log.Errorln("[core] ", err.Error()) 153 | 154 | return 155 | } 156 | 157 | pwt, err := playwright.Run(pwo) 158 | if err != nil { 159 | log.Errorln("[core]", "初始化playwright失败") 160 | log.Errorln("[core] ", err.Error()) 161 | 162 | return 163 | } 164 | c.pw = pwt 165 | browser, err := pwt.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ 166 | Args: []string{ 167 | "--disable-extensions", 168 | "--disable-gpu", 169 | "--start-maximized", 170 | "--no-sandbox", 171 | "--window-size=500,450", 172 | "--mute-audio", 173 | "--window-position=0,0", 174 | "--ignore-certificate-errors", 175 | "--ignore-ssl-errors", 176 | "--disable-features=RendererCodeIntegrity", 177 | "--disable-blink-features", 178 | "--disable-blink-features=AutomationControlled", 179 | }, 180 | Channel: nil, 181 | ChromiumSandbox: nil, 182 | Devtools: nil, 183 | DownloadsPath: nil, 184 | ExecutablePath: nil, 185 | HandleSIGHUP: nil, 186 | HandleSIGINT: nil, 187 | HandleSIGTERM: nil, 188 | Headless: playwright.Bool(!c.ShowBrowser), 189 | Proxy: nil, 190 | SlowMo: nil, 191 | Timeout: nil, 192 | }) 193 | if err != nil { 194 | log.Errorln("[core] ", "初始化chrome失败") 195 | log.Errorln("[core] ", err.Error()) 196 | return 197 | } 198 | c.browser = browser 199 | } 200 | 201 | func isToday(d time.Time) bool { 202 | now := time.Now() 203 | return d.Year() == now.Year() && d.Month() == now.Month() && d.Day() == now.Day() 204 | } 205 | 206 | var studyUserMap = make(map[string]*StudyingJob) 207 | 208 | type StudyingJob struct { 209 | user *model.User 210 | job *model.Job 211 | timer *time.Timer 212 | startSignal chan bool 213 | } 214 | 215 | func (j *StudyingJob) startStudy(immediately ...bool) { 216 | studyUserMap[j.user.Id] = j 217 | defer func() { 218 | // 执行完毕将自己从map中删除 219 | delete(studyUserMap, j.user.Id) 220 | }() 221 | j.startSignal = make(chan bool, 1) 222 | if time.Time(j.user.LastStudyTime).After(time.Now()) { 223 | j.timer = time.NewTimer(time.Time(j.user.LastStudyTime).Sub(time.Now())) 224 | } else { 225 | var randomDuration = time.Second 226 | if len(immediately) == 0 || !immediately[0] { 227 | // 学习时间在当前时间之前 228 | if isToday(time.Time(j.user.LastStudyTime)) { 229 | // 今天的日期,随机延长120s 2分钟 230 | randomDuration = time.Duration(rand.Intn(120)) * time.Second 231 | } else { 232 | // 今天以前的日期,随机延长60 * 120秒 120分钟 233 | randomDuration = time.Duration(rand.Intn(60*120)) * time.Second 234 | 235 | if time.Now().Add(randomDuration).Day() != time.Now().Day() { 236 | randomDuration = time.Duration(rand.Intn(60)) * time.Second 237 | } 238 | } 239 | } 240 | 241 | _, err := zorm.Transaction(context.Background(), func(ctx context.Context) (interface{}, error) { 242 | finder := zorm.NewUpdateFinder(model.UserTableName).Append( 243 | "last_study_time=?, last_score=?", domain.DateTime(time.Now().Add(randomDuration)), 0). 244 | Append("WHERE id=?", j.user.Id) 245 | return zorm.UpdateFinder(ctx, finder) 246 | }) 247 | if err != nil { 248 | logger.Errorln(err) 249 | return 250 | } 251 | 252 | // 随机休眠再开始学习 253 | j.timer = time.NewTimer(randomDuration) 254 | } 255 | select { 256 | case <-j.timer.C: 257 | case <-j.startSignal: 258 | } 259 | 260 | // 休眠完毕,可能有些token已经失效,重新获取用户信息 261 | newUserInfo := new(model.User) 262 | if ok, err := zorm.QueryRow(context.Background(), zorm.NewSelectFinder(model.UserTableName).Append("WHERE id=?", j.user.Id), newUserInfo); err != nil { 263 | logger.Errorln(err) 264 | } else if ok { 265 | j.user = newUserInfo 266 | } 267 | 268 | logger.Infoln(j.user.Nick, "开始学习") 269 | tokenFailed := Core.Learn(j.user, constant.Article) 270 | if tokenFailed { 271 | dealFailedToken(j.user) 272 | return 273 | } 274 | tokenFailed = Core.Learn(j.user, constant.Video) 275 | if tokenFailed { 276 | dealFailedToken(j.user) 277 | return 278 | } 279 | tokenFailed = Core.Answer(j.user, 1) 280 | if tokenFailed { 281 | dealFailedToken(j.user) 282 | return 283 | } 284 | // 每周答题 285 | //tokenFailed = Core.Answer(j.user, 2) 286 | //if tokenFailed { 287 | // dealFailedToken(j.user) 288 | // return 289 | //} 290 | tokenFailed = Core.Answer(j.user, 3) 291 | if tokenFailed { 292 | dealFailedToken(j.user) 293 | return 294 | } 295 | 296 | var score *Score 297 | var err error 298 | 299 | score = Core.Score(j.user) 300 | if score == nil || score.TotalScore == 0 { 301 | for i := 0; i < 5; i++ { 302 | time.Sleep(15 * time.Second) 303 | score = Core.Score(j.user) 304 | if score != nil && score.TodayScore > 0 { 305 | break 306 | } 307 | } 308 | if err != nil { 309 | logger.Errorln(err) 310 | return 311 | } 312 | } 313 | tokenFailed = score == nil || score.TotalScore == 0 314 | if tokenFailed { 315 | dealFailedToken(j.user) 316 | return 317 | } 318 | now := domain.DateTime(time.Now()) 319 | lastCheckTime := now 320 | 321 | if score == nil { 322 | score = &Score{} 323 | lastCheckTime = domain.DateTime(time.Now().Add(-time.Hour)) 324 | } 325 | 326 | _, err = zorm.Transaction(context.Background(), func(ctx context.Context) (interface{}, error) { 327 | return zorm.UpdateNotZeroValue(ctx, &model.User{ 328 | Id: j.user.Id, 329 | LastCheckTime: lastCheckTime, 330 | LastFinishTime: now, 331 | LastScore: score.TodayScore, 332 | Score: score.TotalScore, 333 | }) 334 | }) 335 | if err != nil { 336 | logger.Errorln(err) 337 | return 338 | } 339 | 340 | // 删除job 341 | service.JobService.DeleteById(context.Background(), j.job.Id) 342 | 343 | if config.GetString("communicate.baseUrl") != "" { 344 | util.GetClient().R(). 345 | SetHeader("token", constant.CommunicateHeaderKey). 346 | SetBody(&internalDomain.FinishInfo{ 347 | Nick: j.user.Nick, 348 | Score: score.TodayScore, 349 | }).Post(config.GetString("communicate.baseUrl") + "/api/v1/finishNotify") 350 | } 351 | } 352 | 353 | func dealFailedToken(user *model.User) { 354 | zorm.Transaction(context.Background(), func(ctx context.Context) (interface{}, error) { 355 | _, err := zorm.UpdateNotZeroValue(ctx, &model.User{ 356 | Id: user.Id, 357 | LastCheckTime: domain.DateTime(time.Now()), 358 | Status: -1, 359 | }) 360 | if err != nil { 361 | return nil, err 362 | } 363 | return zorm.Delete(ctx, &model.Job{UserId: user.Id, Status: 1}) 364 | }) 365 | if config.GetString("communicate.baseUrl") != "" { 366 | if config.GetBool("xxqg.expireNotify") { 367 | util.GetClient().R(). 368 | SetHeader("token", constant.CommunicateHeaderKey). 369 | SetBody(&internalDomain.NotifyInfo{ 370 | Nick: user.Nick, 371 | }).Post(config.GetString("communicate.baseUrl") + "/api/v1/expiredNotify") 372 | } 373 | } 374 | return 375 | } 376 | 377 | func StartStudy(user *model.User, jobs ...*model.Job) { 378 | var job *model.Job 379 | if len(jobs) == 0 { 380 | job = &model.Job{ 381 | UserId: user.Id, 382 | Status: 1, 383 | } 384 | service.JobService.DeleteByUserId(context.Background(), user.Id, 1) 385 | service.JobService.Save(context.Background(), job) 386 | } else { 387 | job = jobs[0] 388 | } 389 | sj := &StudyingJob{ 390 | user: user, 391 | job: job, 392 | } 393 | sj.startStudy() 394 | } 395 | 396 | func StartStudyRightNow(user *model.User) { 397 | if sj, has := studyUserMap[user.Id]; has { 398 | close(sj.startSignal) 399 | sj.timer.Stop() 400 | } 401 | 402 | job := &model.Job{ 403 | UserId: user.Id, 404 | Status: 1, 405 | } 406 | service.JobService.DeleteByUserId(context.Background(), user.Id, 1) 407 | service.JobService.Save(context.Background(), job) 408 | sj := &StudyingJob{ 409 | user: user, 410 | job: job, 411 | } 412 | sj.startStudy(true) 413 | } 414 | -------------------------------------------------------------------------------- /internal/study/study.go: -------------------------------------------------------------------------------- 1 | package study 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "math/rand" 7 | "net/http" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/playwright-community/playwright-go" 12 | "github.com/prometheus/common/log" 13 | logger "github.com/sirupsen/logrus" 14 | "github.com/tidwall/gjson" 15 | 16 | "xxqg-automate/internal/constant" 17 | "xxqg-automate/internal/util" 18 | ) 19 | 20 | type Score struct { 21 | TotalScore int `json:"total_score"` 22 | TodayScore int `json:"today_score"` 23 | Content map[string]*Data `json:"content"` 24 | } 25 | type Data struct { 26 | CurrentScore int `json:"current_score"` 27 | MaxScore int `json:"max_score"` 28 | } 29 | 30 | func GetUserScore_declared(cookies []*http.Cookie) (score *Score, tokenFailed bool, err error) { 31 | score = new(Score) 32 | var resp []byte 33 | 34 | header := map[string]string{ 35 | "Cache-Control": "no-cache", 36 | } 37 | 38 | client := util.GetClient() 39 | response, err := client.R().SetCookies(cookies...).SetHeaders(header).Get(constant.XxqgUrlTotalScore) 40 | if err != nil { 41 | logger.Errorln("获取用户总分错误"+err.Error(), string(response.Bytes())) 42 | return nil, false, err 43 | } 44 | resp = response.Bytes() 45 | 46 | score.TotalScore = int(gjson.GetBytes(resp, "data.score").Int()) 47 | 48 | response, err = client.R().SetCookies(cookies...).SetHeaders(header).Get(constant.XxqgUrlTodayTotalScore) 49 | if err != nil { 50 | log.Errorln("获取用户总分错误"+err.Error(), string(response.Bytes())) 51 | return nil, false, err 52 | } 53 | resp = response.Bytes() 54 | score.TodayScore = int(gjson.GetBytes(resp, "data.score").Int()) 55 | 56 | response, err = client.R().SetCookies(cookies...).SetHeaders(header).Get(constant.XxqgUrlRateScore) 57 | if err != nil { 58 | log.Errorln("获取用户总分错误"+err.Error(), string(response.Bytes())) 59 | return nil, false, err 60 | } 61 | resp = response.Bytes() 62 | j := gjson.ParseBytes(resp) 63 | taskProgress := j.Get("data.taskProgress").Array() 64 | if len(taskProgress) == 0 { 65 | if j.Get("code").Int() == 401 { 66 | // 校验失败 67 | return nil, true, nil 68 | } 69 | 70 | logger.Warnln("未获取到data.taskProgress: ", string(resp)) 71 | return nil, false, errors.New("未成功获取用户积分信息") 72 | } 73 | m := make(map[string]*Data, 7) 74 | m[constant.Article] = &Data{ 75 | CurrentScore: int(taskProgress[0].Get("currentScore").Int()), 76 | MaxScore: int(taskProgress[0].Get("dayMaxScore").Int()), 77 | } 78 | m[constant.Video] = &Data{ 79 | CurrentScore: int(taskProgress[1].Get("currentScore").Int()), 80 | MaxScore: int(taskProgress[1].Get("dayMaxScore").Int()), 81 | } 82 | m["video_time"] = &Data{ 83 | CurrentScore: int(taskProgress[2].Get("currentScore").Int()), 84 | MaxScore: int(taskProgress[2].Get("dayMaxScore").Int()), 85 | } 86 | m["login"] = &Data{ 87 | CurrentScore: int(taskProgress[3].Get("currentScore").Int()), 88 | MaxScore: int(taskProgress[3].Get("dayMaxScore").Int()), 89 | } 90 | m["special"] = &Data{ 91 | CurrentScore: int(taskProgress[4].Get("currentScore").Int()), 92 | MaxScore: int(taskProgress[4].Get("dayMaxScore").Int()), 93 | } 94 | m["daily"] = &Data{ 95 | CurrentScore: int(taskProgress[5].Get("currentScore").Int()), 96 | MaxScore: int(taskProgress[5].Get("dayMaxScore").Int()), 97 | } 98 | 99 | score.Content = m 100 | return 101 | } 102 | 103 | type Link struct { 104 | Editor string `json:"editor"` 105 | PublishTime string `json:"publishTime"` 106 | ItemType string `json:"itemType"` 107 | Author string `json:"author"` 108 | CrossTime int `json:"crossTime"` 109 | Source string `json:"source"` 110 | NameB string `json:"nameB"` 111 | Title string `json:"title"` 112 | Type string `json:"type"` 113 | Url string `json:"url"` 114 | ShowSource string `json:"showSource"` 115 | ItemId string `json:"itemId"` 116 | ThumbImage string `json:"thumbImage"` 117 | AuditTime string `json:"auditTime"` 118 | ChannelNames []string `json:"channelNames"` 119 | Producer string `json:"producer"` 120 | ChannelIds []string `json:"channelIds"` 121 | DataValid bool `json:"dataValid"` 122 | } 123 | 124 | func getLinks(model string) ([]Link, error) { 125 | UID := rand.Intn(20000000) + 10000000 126 | learnUrl := "" 127 | if model == constant.Article { 128 | learnUrl = constant.ArticleUrlList[rand.Intn(len(constant.ArticleUrlList))] 129 | } else if model == constant.Video { 130 | learnUrl = constant.VideoUrlList[rand.Intn(len(constant.VideoUrlList))] 131 | } else { 132 | return nil, errors.New("获取连接模块不支持") 133 | } 134 | var ( 135 | resp []byte 136 | ) 137 | 138 | response, err := util.GetClient().R().SetQueryParam("_st", strconv.Itoa(UID)).Get(learnUrl) 139 | if err != nil { 140 | logger.Errorln("请求链接列表出现错误!" + err.Error()) 141 | return nil, err 142 | } 143 | resp = response.Bytes() 144 | 145 | var links []Link 146 | err = json.Unmarshal(resp, &links) 147 | if err != nil { 148 | logger.Errorln("解析列表出现错误" + err.Error()) 149 | return nil, err 150 | } 151 | return links, err 152 | } 153 | 154 | func TokenToCookies(token string) []*http.Cookie { 155 | cookie := &http.Cookie{ 156 | Name: "token", 157 | Value: token, 158 | Path: "/", 159 | Domain: "xuexi.cn", 160 | Expires: time.Now().Add(time.Hour * 12), 161 | Secure: false, 162 | HttpOnly: false, 163 | SameSite: http.SameSiteStrictMode, 164 | } 165 | return []*http.Cookie{cookie} 166 | } 167 | 168 | func ToBrowserCookies(token string) []playwright.BrowserContextAddCookiesOptionsCookies { 169 | cookie := playwright.BrowserContextAddCookiesOptionsCookies{ 170 | Name: playwright.String("token"), 171 | Value: playwright.String(token), 172 | Path: playwright.String("/"), 173 | Domain: playwright.String(".xuexi.cn"), 174 | Expires: playwright.Float(float64(time.Now().Add(time.Hour * 12).Unix())), 175 | Secure: playwright.Bool(false), 176 | HttpOnly: playwright.Bool(false), 177 | SameSite: playwright.SameSiteAttributeStrict, 178 | } 179 | return []playwright.BrowserContextAddCookiesOptionsCookies{cookie} 180 | } 181 | -------------------------------------------------------------------------------- /internal/update/doc.go: -------------------------------------------------------------------------------- 1 | // Package update 该包为程序自我更新,代码源于https://github.com/Mrs4s/go-cqhttp/blob/master/internal/selfupdate/update.go 2 | package update 3 | -------------------------------------------------------------------------------- /internal/update/index.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "bufio" 5 | "encoding/hex" 6 | "fmt" 7 | "hash" 8 | "io" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/dustin/go-humanize" 17 | "github.com/kardianos/osext" 18 | logger "github.com/sirupsen/logrus" 19 | "github.com/tidwall/gjson" 20 | ) 21 | 22 | func SelfUpdate(github string, version string) { 23 | if github == "" { 24 | github = "https://github.com" 25 | } 26 | 27 | if version == "unknown" { 28 | logger.Warningln("测试版本,不更新!") 29 | return 30 | } 31 | 32 | logger.Infoln("正在检查更新.") 33 | latest, err := lastVersion() 34 | if err != nil { 35 | logger.Warnf("获取最新版本失败: %v \n", err) 36 | wait() 37 | } 38 | url := fmt.Sprintf("%v/yockii/xxqg-automate/releases/download/%v/%v", github, latest, binaryName()) 39 | if version == latest { 40 | logger.Infoln("当前版本已经是最新版本!") 41 | wait() 42 | } 43 | logger.Infoln("当前最新版本为 ", latest) 44 | logger.Infoln("正在更新,请稍等...") 45 | sum := checksum(github, latest) 46 | if sum != nil { 47 | err = update(url, sum) 48 | if err != nil { 49 | logger.Errorln("更新失败: ", err) 50 | } else { 51 | logger.Infoln("更新成功!") 52 | } 53 | } else { 54 | logger.Errorln("checksum 失败!") 55 | } 56 | } 57 | func checksum(github, version string) []byte { 58 | sumURL := fmt.Sprintf("%v/yockii/xxqg-automate/releases/download/%v/xxqg-automate_checksums.txt", github, version) 59 | response, err := http.Get(sumURL) 60 | if err != nil { 61 | return nil 62 | } 63 | rd := bufio.NewReader(response.Body) 64 | for { 65 | str, err := rd.ReadString('\n') 66 | if err != nil { 67 | break 68 | } 69 | str = strings.TrimSpace(str) 70 | if strings.HasSuffix(str, binaryName()) { 71 | sum, _ := hex.DecodeString(strings.TrimSuffix(str, " "+binaryName())) 72 | return sum 73 | } 74 | } 75 | return nil 76 | } 77 | 78 | func binaryName() string { 79 | goarch := runtime.GOARCH 80 | if goarch == "arm" { 81 | goarch += "v7" 82 | } 83 | ext := "tar.gz" 84 | if runtime.GOOS == "windows" { 85 | ext = "zip" 86 | } 87 | return fmt.Sprintf("xxqg-automate_%v_%v.%v", runtime.GOOS, goarch, ext) 88 | } 89 | 90 | func wait() { 91 | logger.Info("按 Enter 继续....") 92 | readLine() 93 | os.Exit(0) 94 | } 95 | 96 | func readLine() (str string) { 97 | console := bufio.NewReader(os.Stdin) 98 | str, _ = console.ReadString('\n') 99 | str = strings.TrimSpace(str) 100 | return 101 | } 102 | func CheckUpdate(version string) string { 103 | logger.Infoln("正在检查更新.") 104 | if version == "(devel)" { 105 | logger.Warnln("检查更新失败: 使用的 Actions 测试版或自编译版本.") 106 | return "" 107 | } 108 | if version == "unknown" { 109 | logger.Warnln("检查更新失败: 使用的未知版本.") 110 | return "" 111 | } 112 | 113 | if !strings.HasPrefix(version, "v") { 114 | logger.Warnln("版本格式错误") 115 | return "" 116 | } 117 | latest, err := lastVersion() 118 | if err != nil { 119 | logger.Warnf("检查更新失败: %v \n", err) 120 | return "" 121 | } 122 | if versionCompare(version, latest) { 123 | logger.Infoln("当前有更新的 xxqg-automate 可供更新, 请前往 https://github.com/yockii/xxqg-automate/releases 下载.") 124 | logger.Infof("当前版本: %v 最新版本: %v \n", version, latest) 125 | return "检测到可用更新,版本号:" + latest 126 | } 127 | logger.Infoln("检查更新完成. 当前已运行最新版本.") 128 | return "" 129 | } 130 | 131 | func lastVersion() (string, error) { 132 | response, err := http.Get("https://api.github.com/repos/yockii/xxqg-automate/releases/latest") 133 | if err != nil { 134 | return "", err 135 | } 136 | data, _ := io.ReadAll(response.Body) 137 | defer response.Body.Close() 138 | return gjson.GetBytes(data, "tag_name").Str, nil 139 | } 140 | func versionCompare(nowVersion, lastVersion string) bool { 141 | NowBeta := strings.Contains(nowVersion, "beta") 142 | LastBeta := strings.Contains(lastVersion, "beta") 143 | 144 | // 获取主要版本号 145 | nowMainVersion := strings.Split(nowVersion, "-") 146 | lastMainVersion := strings.Split(lastVersion, "-") 147 | 148 | nowMainIntVersion, _ := strconv.Atoi(strings.ReplaceAll(strings.TrimLeft(nowMainVersion[0], "v"), ".", "")) 149 | lastMainIntVersion, _ := strconv.Atoi(strings.ReplaceAll(strings.TrimLeft(lastMainVersion[0], "v"), ".", "")) 150 | 151 | if nowMainIntVersion < lastMainIntVersion { 152 | return true 153 | } 154 | if strings.Contains(nowVersion, "SNAPSHOT") { 155 | if nowMainIntVersion == lastMainIntVersion { 156 | return false 157 | } else { 158 | return true 159 | } 160 | } 161 | // 如果最新版本是beta 162 | if LastBeta { 163 | // 如果当前版本也是beta 164 | if NowBeta { 165 | // 对beta后面的数字进行比较 166 | nowBetaVersion, _ := strconv.Atoi(strings.TrimLeft(nowMainVersion[1], "beta")) 167 | lastBetaVersion, _ := strconv.Atoi(strings.TrimLeft(lastMainVersion[1], "beta")) 168 | if nowBetaVersion < lastBetaVersion { 169 | return true 170 | } 171 | return false 172 | // 如果当前版本部署beta,需要更新 173 | } else { 174 | return true 175 | } 176 | // 最新版本不是beta,需要更新 177 | } else { 178 | return false 179 | } 180 | } 181 | 182 | func fromStream(updateWith io.Reader) (err error, errRecover error) { 183 | updatePath, err := osext.Executable() 184 | if err != nil { 185 | return 186 | } 187 | 188 | // get the directory the executable exists in 189 | updateDir := filepath.Dir(updatePath) 190 | filename := filepath.Base(updatePath) 191 | // Copy the contents of of newbinary to a the new executable file 192 | newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename)) 193 | fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) 194 | if err != nil { 195 | return 196 | } 197 | // We won't log this error, because it's always going to happen. 198 | defer func() { _ = fp.Close() }() 199 | if _, err = io.Copy(fp, bufio.NewReader(updateWith)); err != nil { 200 | logger.Errorf("Unable to copy data: %v\n", err) 201 | } 202 | 203 | // if we don't call fp.Close(), windows won't let us move the new executable 204 | // because the file will still be "in use" 205 | if err = fp.Close(); err != nil { 206 | logger.Errorf("Unable to close file: %v\n", err) 207 | } 208 | // this is where we'll move the executable to so that we can swap in the updated replacement 209 | oldPath := filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename)) 210 | 211 | // delete any existing old exec file - this is necessary on Windows for two reasons: 212 | // 1. after a successful update, Windows can't remove the .old file because the process is still running 213 | // 2. windows rename operations fail if the destination file already exists 214 | _ = os.Remove(oldPath) 215 | 216 | // move the existing executable to a new file in the same directory 217 | err = os.Rename(updatePath, oldPath) 218 | if err != nil { 219 | return 220 | } 221 | 222 | // move the new executable in to become the new program 223 | err = os.Rename(newPath, updatePath) 224 | 225 | if err != nil { 226 | // copy unsuccessful 227 | errRecover = os.Rename(oldPath, updatePath) 228 | } else { 229 | // copy successful, remove the old binary 230 | _ = os.Remove(oldPath) 231 | } 232 | return 233 | } 234 | 235 | type writeSumCounter struct { 236 | total uint64 237 | hash hash.Hash 238 | } 239 | 240 | func (wc *writeSumCounter) Write(p []byte) (int, error) { 241 | n := len(p) 242 | wc.total += uint64(n) 243 | wc.hash.Write(p) 244 | fmt.Printf("\r ") 245 | fmt.Printf("\rDownloading... %s complete", humanize.Bytes(wc.total)) 246 | return n, nil 247 | } 248 | -------------------------------------------------------------------------------- /internal/update/other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package update 5 | 6 | import ( 7 | "archive/tar" 8 | "bytes" 9 | "compress/gzip" 10 | "crypto/sha256" 11 | "errors" 12 | "io" 13 | "net/http" 14 | ) 15 | 16 | func update(url string, sum []byte) error { 17 | resp, err := http.Get(url) 18 | if err != nil { 19 | return err 20 | } 21 | defer resp.Body.Close() 22 | wc := writeSumCounter{ 23 | hash: sha256.New(), 24 | } 25 | rsp, err := io.ReadAll(io.TeeReader(resp.Body, &wc)) 26 | if err != nil { 27 | return err 28 | } 29 | if !bytes.Equal(wc.hash.Sum(nil), sum) { 30 | return errors.New("文件已损坏") 31 | } 32 | gr, err := gzip.NewReader(bytes.NewReader(rsp)) 33 | if err != nil { 34 | return err 35 | } 36 | tr := tar.NewReader(gr) 37 | for { 38 | header, err := tr.Next() 39 | if err != nil { 40 | return err 41 | } 42 | if header.Name == "xxqg-automate" { 43 | err, _ := fromStream(tr) 44 | if err != nil { 45 | return err 46 | } 47 | return nil 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/update/win.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package update 5 | 6 | import ( 7 | "archive/zip" 8 | "bytes" 9 | "crypto/sha256" 10 | "errors" 11 | "io" 12 | "net/http" 13 | ) 14 | 15 | func update(url string, sum []byte) error { 16 | resp, err := http.Get(url) 17 | if err != nil { 18 | return err 19 | } 20 | defer resp.Body.Close() 21 | wc := writeSumCounter{ 22 | hash: sha256.New(), 23 | } 24 | rsp, err := io.ReadAll(io.TeeReader(resp.Body, &wc)) 25 | if err != nil { 26 | return err 27 | } 28 | if !bytes.Equal(wc.hash.Sum(nil), sum) { 29 | return errors.New("文件已损坏") 30 | } 31 | reader, _ := zip.NewReader(bytes.NewReader(rsp), resp.ContentLength) 32 | file, err := reader.Open("xxqg-automate.exe") 33 | if err != nil { 34 | return err 35 | } 36 | err, _ = fromStream(file) 37 | if err != nil { 38 | return err 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/util/Crypt.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | ) 7 | 8 | func HmacSha256(data, secret []byte) []byte { 9 | h := hmac.New(sha256.New, secret) 10 | h.Write(data) 11 | return h.Sum(nil) 12 | } 13 | -------------------------------------------------------------------------------- /internal/util/File.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "os" 7 | ) 8 | 9 | // FileIsExist 10 | /* @Description: 11 | * @param path 12 | * @return bool 13 | */ 14 | func FileIsExist(path string) bool { 15 | _, err := os.Stat(path) 16 | if err == nil { 17 | return true 18 | } 19 | if os.IsNotExist(err) { 20 | return false 21 | } 22 | return false 23 | } 24 | 25 | // StrMd5 26 | /* @Description: 27 | * @param str 28 | * @return retMd5 29 | */ 30 | func StrMd5(str string) (retMd5 string) { 31 | h := md5.New() 32 | h.Write([]byte(str)) 33 | return hex.EncodeToString(h.Sum(nil)) 34 | } 35 | -------------------------------------------------------------------------------- /internal/util/QuestionBank.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "io" 7 | "os" 8 | "time" 9 | 10 | "github.com/imroc/req/v3" 11 | logger "github.com/sirupsen/logrus" 12 | 13 | "xxqg-automate/internal/constant" 14 | ) 15 | 16 | var ( 17 | dbSum = "d6e455f03b419af108cced07ea1d17f8268400ad1b6d80cb75d58e952a5609bf" 18 | ) 19 | 20 | func CheckQuestionDB() bool { 21 | 22 | if !FileIsExist(constant.QuestionBankDBFile) { 23 | return false 24 | } 25 | f, err := os.Open(constant.QuestionBankDBFile) 26 | if err != nil { 27 | logger.Errorln(err.Error()) 28 | return false 29 | } 30 | 31 | defer f.Close() 32 | h := sha256.New() 33 | //h := sha1.New() 34 | //h := sha512.New() 35 | 36 | if _, err = io.Copy(h, f); err != nil { 37 | logger.Errorln(err.Error()) 38 | return false 39 | } 40 | 41 | // 格式化为16进制字符串 42 | sha := fmt.Sprintf("%x", h.Sum(nil)) 43 | logger.Infoln("db_sha: " + sha) 44 | if sha != dbSum { 45 | return false 46 | } 47 | return true 48 | } 49 | 50 | func DownloadDbFile() { 51 | defer func() { 52 | err := recover() 53 | if err != nil { 54 | logger.Errorln("下载题库文件意外错误") 55 | logger.Errorln(err) 56 | } 57 | }() 58 | logger.Infoln("正在从github下载题库文件!") 59 | 60 | callback := func(info req.DownloadInfo) { 61 | fmt.Printf("download %.2f%%\n", float64(info.DownloadedSize)/float64(info.Response.ContentLength)*100.0) 62 | } 63 | 64 | _, err := GetClient().R(). 65 | SetOutputFile(constant.QuestionBankDBFile). 66 | SetDownloadCallbackWithInterval(callback, time.Second). 67 | Get("https://github.com/johlanse/study_xxqg/raw/main/conf/QuestionBank.db") 68 | 69 | //response, err := http.Get("https://github.com/johlanse/study_xxqg/raw/main/conf/QuestionBank.db") 70 | ////response, err := http.Get("https://github.com/johlanse/study_xxqg/releases/download/v1.0.36/QuestionBank.db") 71 | //if err != nil { 72 | // logger.Errorln("下载db文件错误" + err.Error()) 73 | // return 74 | //} 75 | //data, _ := io.ReadAll(response.Body) 76 | //err = os.WriteFile(constant.QuestionBankDBFile, data, 0666) 77 | 78 | if err != nil { 79 | logger.Errorln(err.Error()) 80 | return 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/util/client.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/imroc/req/v3" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | var client *req.Client 11 | 12 | func init() { 13 | client = req.C() 14 | client.SetProxy(http.ProxyFromEnvironment) 15 | if log.GetLevel() == log.DebugLevel { 16 | //client.DebugLog = true 17 | //client = client.DevMode() 18 | } 19 | //client.SetLogger(&myLog{}) 20 | client.SetCommonHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36") 21 | } 22 | 23 | func GetClient() *req.Client { 24 | return client.EnableInsecureSkipVerify() 25 | } 26 | 27 | type myLog struct { 28 | } 29 | 30 | func (m myLog) Errorf(format string, v ...interface{}) { 31 | log.Errorf(format, v) 32 | } 33 | 34 | func (m myLog) Warnf(format string, v ...interface{}) { 35 | log.Warnf(format, v) 36 | } 37 | 38 | func (m myLog) Debugf(format string, v ...interface{}) { 39 | log.Debugf(format, v) 40 | } 41 | 42 | type LogWriter struct { 43 | } 44 | 45 | func (l *LogWriter) Write(p []byte) (n int, err error) { 46 | log.Debugln(string(p)) 47 | return len(p), nil 48 | } 49 | --------------------------------------------------------------------------------