package ckwk import ( "ckwk/internal/conf" "ckwk/pkg/common" "ckwk/pkg/log" "ckwk/pkg/request" "encoding/base64" "encoding/json" "fmt" "net/http" "regexp" "strconv" "strings" "github.com/antchfx/htmlquery" "go.uber.org/zap" "resty.dev/v3" ) type WK struct { Username string `json:"username"` Password string `json:"password"` Host string `json:"host"` Req *resty.Client Cookies []*http.Cookie LoginRegexp *regexp.Regexp CourseIDRegexp *regexp.Regexp TimeRegexp *regexp.Regexp } func NewWK(username, password, host string, cookies []*http.Cookie) *WK { hasAuth := (username != "" && password != "") || len(cookies) > 0 if host == "" || !hasAuth { return nil } req := request.NewClient(&request.Config{ UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0", Proxy: "http://127.0.0.1:9000", VerifySSL: false, }) if len(cookies) > 0 { req.SetCookies(cookies) } wk := &WK{ Username: username, Password: password, Host: host, Req: req, Cookies: cookies, LoginRegexp: regexp.MustCompile("var data =(.*?);"), CourseIDRegexp: regexp.MustCompile(`\?courseId=(\d+)`), TimeRegexp: regexp.MustCompile(`\d{4}-\d{2}-\d{2}`), } if len(cookies) == 0 && username != "" { wk.Login() } return wk } // Cookies: returns cookies func (wk *WK) Cookie() []*http.Cookie { return wk.Cookies } // SetCookies: returns cookies func (wk *WK) SetCookies(cs []*http.Cookie) { wk.Cookies = cs wk.Req.SetCookies(cs) } // Code: Get Verify Code func (wk *WK) Code() (string, error) { resp, err := wk.Req. R(). SetQueryParam("r", fmt.Sprint(common.RandFloat64())). Get(fmt.Sprintf("https://%s/service/code", wk.Host)) if err != nil { return "", fmt.Errorf("获取验证码失败: %w", err) } if resp.StatusCode() != 200 { return "", fmt.Errorf("获取验证码失败: code: %d", resp.StatusCode()) } var result CodeResp _, err = wk.Req. R(). SetFormData(map[string]string{ "image": base64.StdEncoding.EncodeToString(resp.Bytes()), "probability": "false", "png_fix": "false", }). SetResult(&result). Post(fmt.Sprintf("%s/ocr", conf.DdddOCR)) if err != nil { return "", fmt.Errorf("获取验证码验证结果失败: %w", err) } if result.Code != 200 { return "", fmt.Errorf("ocr 失败: code: %d, message: %s", result.Code, result.Message) } return result.Data, nil } // Login: Login WebSite func (wk *WK) Login() (bool, error) { yzm := "" for i := 1; i <= 3; i++ { yzm, _ = wk.Code() if yzm != "" { break } log.Warnf("第 %d 次获取验证码失败, 正在重试...\n", i) } if yzm == "" { return false, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。") } resp, err := wk.Req. R(). SetFormData(map[string]string{ "username": wk.Username, "password": wk.Password, "code": yzm, "redirect": "", }). Post(fmt.Sprintf("https://%s/user/login", wk.Host)) if err != nil { return false, fmt.Errorf("请求登录失败: %w", err) } matchs := wk.LoginRegexp.FindStringSubmatch(string(resp.Bytes())) if len(matchs) <= 1 { return false, fmt.Errorf("没有找到匹配字符串") } var result LoginResp err = json.Unmarshal([]byte(matchs[1]), &result) if err != nil { return false, fmt.Errorf("解析 json data 失败: %w", err) } if !result.Status { return false, fmt.Errorf("登录失败: %s", result.Msg) } wk.SetCookies(resp.Cookies()) log.Info("登录成功", zap.Any("cookies", wk.Cookies)) return true, nil } // CourseParse: 课程解析 func (wk *WK) CourseParse(content string) ([]Course, error) { courses := make([]Course, 0) doc, err := htmlquery.Parse(strings.NewReader(content)) if err != nil { return courses, fmt.Errorf("解析 HTML 失败: %w", err) } itemsNode := htmlquery.Find(doc, `//div[@class="user-course"]/div[@class="item"]`) if len(itemsNode) < 1 { return courses, nil // return courses, fmt.Errorf("未解析到课程") } for _, node := range itemsNode { aNode := htmlquery.FindOne(node, `.//div[@class="name"]/a`) if aNode == nil { continue } href := htmlquery.SelectAttr(aNode, "href") match := wk.CourseIDRegexp.FindStringSubmatch(href) var id int if len(match) > 1 { idVal, err := strconv.Atoi(match[1]) if err == nil { id = idVal } else { log.Warn("ID 转换失败", zap.Error(err)) } } tagsNode := htmlquery.FindOne(node, `.//div[@class="tags"]/span`) progressNode := htmlquery.FindOne(node, `.//div[@class="progress"]/div[@class="txt"]`) timeNode := htmlquery.FindOne(node, `.//div[@class="time"]`) var startTime, stopTime string if timeNode != nil { fullText := htmlquery.InnerText(timeNode) dates := wk.TimeRegexp.FindAllString(fullText, -1) if len(dates) >= 2 { startTime = dates[0] stopTime = dates[1] } } var credit float32 creditNode := htmlquery.FindOne(node, `.//div[@class="note"]/div[@class="number"]/span`) creditStr := strings.TrimSpace(htmlquery.InnerText(creditNode)) creditVal, err := strconv.ParseFloat(creditStr, 32) if err == nil { credit = float32(creditVal) } else { log.Warn("课程学分转换失败", zap.Error(err)) } typeNode := htmlquery.FindOne(node, `.//div[@class="note"]/div[@class="kind"]/span`) course := Course{ ID: id, Name: htmlquery.InnerText(aNode), Teacher: htmlquery.InnerText(tagsNode), Progress: htmlquery.InnerText(progressNode), StartTime: startTime, StopTime: stopTime, Credit: credit, Type: htmlquery.InnerText(typeNode), } courses = append(courses, course) } return courses, nil } // CourseGet: 课程获取 func (wk *WK) CourseGet(kind CourseKind) ([]Course, error) { var courses []Course resp, err := wk.Req. R(). SetQueryParam("kind", string(kind)). Get(fmt.Sprintf("https://%s/user/index", wk.Host)) if err != nil { return courses, fmt.Errorf("获取课程失败: %w", err) } courses, err = wk.CourseParse(string(resp.Bytes())) if err != nil { return courses, fmt.Errorf("解析课程失败: %w", err) } return courses, nil } // UserParse: 用户信息解析 func (wk *WK) UserInfoParse(content string) (User, error) { var user User doc, err := htmlquery.Parse(strings.NewReader(content)) if err != nil { return user, fmt.Errorf("解析用户信息失败: %w", err) } userInfoNode := htmlquery.FindOne(doc, `//div[@class="useredata-form"]`) if userInfoNode == nil { return user, fmt.Errorf("未解析到用户信息") } nameNode := htmlquery.FindOne(userInfoNode, `.//div[@class="item"][1]/div[@class="txt"]/text()`) if nameNode != nil { user.Name = strings.TrimSpace(htmlquery.InnerText(nameNode)) } idNode := htmlquery.FindOne(userInfoNode, `.//div[@class="item"][2]/div[@class="txt"]`) if idNode != nil { user.ID = strings.TrimSpace(htmlquery.InnerText(idNode)) } deptNode := htmlquery.FindOne(userInfoNode, `.//div[@class="item"][3]/div[@class="txt"]`) if deptNode != nil { user.Dept = strings.TrimSpace(htmlquery.InnerText(deptNode)) } classNode := htmlquery.FindOne(userInfoNode, `.//div[@class="item"][4]/div[@class="txt"]`) if classNode != nil { user.Class = strings.TrimSpace(htmlquery.InnerText(classNode)) } genderNode := htmlquery.FindOne(userInfoNode, `.//select[@id="gender"]/option[@selected="selected"]`) if genderNode != nil { user.Gender = htmlquery.SelectAttr(genderNode, "value") } return user, nil } // UserGet: 用户信息获取 func (wk *WK) UserInfoGet() (User, error) { var user User resp, err := wk.Req. R(). Get(fmt.Sprintf("https://%s/user/member", wk.Host)) if err != nil { return user, fmt.Errorf("获取用户信息页面失败: %w", err) } user, err = wk.UserInfoParse(string(resp.Bytes())) if err != nil { return user, err } wk.Username = user.ID return user, nil } // Online: 保持账号状态 func (wk *WK) Online() (bool, error) { resp, err := wk.Req. R(). SetHeaders(map[string]string{ "x-requested-with": "XMLHttpRequest", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Encoding": "gzip, deflate, br, zstd", }). Post(fmt.Sprintf("https://%s/user/online", wk.Host)) if err != nil { return false, fmt.Errorf("保持账号状态失败: %w", err) } log.Info("保持账号状态", zap.Any("resp", string(resp.Bytes()))) return true, nil } // Study: 学习 func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*StudyResp, error) { var data map[string]string switch status { case StudyStart: yzm := "" for i := 1; i <= 3; i++ { yzm, _ = wk.Code() if yzm != "" { break } log.Warnf("第 %d 次获取验证码失败, 正在重试...\n", i) } if yzm == "" { return nil, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。") } data = map[string]string{ "nodeId": nodeID, "studyId": "0", "studyTime": "1", "code": yzm, } case Study: data = map[string]string{ "nodeId": nodeID, "studyId": studyID, "studyTime": studyTime, } case StudyOver: data = map[string]string{ "nodeId": nodeID, "studyId": studyID, "studyTime": studyTime, "close": "1", } default: return nil, fmt.Errorf("传入的学习状态不匹配") } resp, err := wk.Req. R(). SetHeaders(map[string]string{ "x-requested-with": "XMLHttpRequest", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Encoding": "gzip, deflate, br, zstd", }). SetFormData(data). Post(fmt.Sprintf("https://%s/user/node/study", wk.Host)) if err != nil { return nil, fmt.Errorf("进行学习失败: %w", err) } var result StudyResp if err := json.Unmarshal(resp.Bytes(), &result); err != nil { return nil, fmt.Errorf("解析响应失败: %w", err) } log.Info("学习响应", zap.Any("resp", result)) return &result, nil } // GetStudyList: 获取学习记录 func (wk *WK) GetStudyList(courseID, page string) (*AllRecordResp[StudyList], error) { return GetRecords[StudyList](wk, RecordStudy, courseID, page) } // GetExamList: 获取作业记录 // todo func (wk *WK) GetWorkList(courseID, page string) (*AllRecordResp[ExamList], error) { return GetRecords[ExamList](wk, RecordStudy, courseID, page) } // GetExamList: 获取考试记录 func (wk *WK) GetExamList(courseID, page string) (*AllRecordResp[ExamList], error) { return GetRecords[ExamList](wk, RecordExam, courseID, page) } // GetExamList: 获取讨论记录 // todo func (wk *WK) GetDiscussList(courseID, page string) (*AllRecordResp[ExamList], error) { return GetRecords[ExamList](wk, RecordStudy, courseID, page) }