package ckwk import ( "ckwk/internal/conf" "ckwk/pkg/common" "ckwk/pkg/log" "ckwk/pkg/request" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "regexp" "strconv" "strings" "sync" "github.com/antchfx/htmlquery" "go.uber.org/zap" "resty.dev/v3" ) var ( ErrLoginTimeout = errors.New("登录超时,请重新登录") ErrSessionRemoved = errors.New("session_manager 已删除失效会话") ErrReloginSkipped = errors.New("当前会话缺少账号密码,无法自动重新登录") ) const maxAutoReloginRetries = 3 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 authMu sync.Mutex sessionID string sessionManager *SessionManager } func NewWK(username, password, host string, cookies []*http.Cookie) *WK { hasAuth := (username != "" && password != "") || len(cookies) > 0 if host == "" || !hasAuth { return nil } reqCfg := &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", VerifySSL: true, Debug: conf.IsRuntimeDebugEnabled(), } if conf.IsRuntimeDebugEnabled() { reqCfg.Proxy = conf.DebugProxy reqCfg.VerifySSL = !conf.DebugSkipSSLVerify } req := request.NewClient(reqCfg) 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}`), } return wk } func (wk *WK) bindSession(sm *SessionManager, sessionID string) { wk.sessionManager = sm wk.sessionID = sessionID } func (wk *WK) prepareRequestClient() { if wk == nil || wk.Req == nil { return } cfg := &request.Config{ UserAgent: request.DefaultUserAgent, VerifySSL: true, Debug: conf.IsRuntimeDebugEnabled(), } if conf.IsRuntimeDebugEnabled() { cfg.Proxy = conf.DebugProxy cfg.VerifySSL = !conf.DebugSkipSSLVerify } request.ApplyConfig(wk.Req, cfg) if len(wk.Cookies) > 0 { wk.Req.SetCookies(wk.Cookies) } } func (wk *WK) newRequest() *resty.Request { wk.prepareRequestClient() return wk.Req.R() } // 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.newRequest(). 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.newRequest(). 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.newRequest(). 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) } result, err := wk.parseLoginResp(resp.Bytes()) if err != nil { return false, err } if !result.Status { return false, fmt.Errorf("登录失败: %s", loginErrorMessage(result)) } wk.SetCookies(resp.Cookies()) log.Info("登录成功", zap.Any("cookies", wk.Req.Cookies())) return true, nil } func (wk *WK) parseLoginResp(body []byte) (LoginResp, error) { var result LoginResp if len(body) == 0 { return result, fmt.Errorf("登录响应为空") } matchs := wk.LoginRegexp.FindStringSubmatch(string(body)) if len(matchs) > 1 { if err := json.Unmarshal([]byte(strings.TrimSpace(matchs[1])), &result); err != nil { return result, fmt.Errorf("解析登录响应失败: %w", err) } return result, nil } if err := json.Unmarshal(body, &result); err != nil { return result, fmt.Errorf("解析登录响应失败: %w", err) } return result, nil } func loginErrorMessage(result LoginResp) string { if msg := strings.TrimSpace(result.FormError.Code); msg != "" { return msg } if msg := strings.TrimSpace(result.Msg); msg != "" { return msg } return "未知错误" } // 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.newRequest(). 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.newRequest(). 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) performOnline() (bool, error) { resp, err := wk.newRequest(). 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) } if isLoginTimeoutBody(resp.Bytes()) { return false, fmt.Errorf("保持账号状态失败: %w", ErrLoginTimeout) } log.Info("保持账号状态", zap.Any("resp", string(resp.Bytes()))) return true, nil } func (wk *WK) Online() (bool, error) { return withAutoRelogin(wk, "保持账号状态", wk.performOnline) } // Study: 学习 func (wk *WK) performStudy(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.newRequest(). 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) } if isLoginTimeoutBody(resp.Bytes()) { return nil, fmt.Errorf("进行学习失败: %w", ErrLoginTimeout) } 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 } func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*StudyResp, error) { return withAutoRelogin(wk, "进行学习", func() (*StudyResp, error) { return wk.performStudy(nodeID, studyID, studyTime, status) }) } // 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) } func isLoginTimeoutBody(body []byte) bool { if len(body) == 0 { return false } var payload struct { Offline int `json:"offline"` Status bool `json:"status"` Msg string `json:"msg"` } if err := json.Unmarshal(body, &payload); err != nil { return false } return payload.Offline == 1 && !payload.Status && strings.Contains(payload.Msg, "登录超时") } func (wk *WK) reloginForExpiredSession(action string, attempt int) error { wk.authMu.Lock() defer wk.authMu.Unlock() if strings.TrimSpace(wk.Username) == "" || strings.TrimSpace(wk.Password) == "" { return fmt.Errorf("%w: 检测到 %s 登录超时", ErrReloginSkipped, action) } log.Warn("检测到登录超时,开始自动重新登录", zap.String("action", action), zap.String("host", wk.Host), zap.String("username", wk.Username), zap.Int("attempt", attempt), zap.Int("maxAttempts", maxAutoReloginRetries), ) ok, err := wk.Login() if err != nil { return fmt.Errorf("第 %d 次自动重新登录失败: %w", attempt, err) } if !ok { return fmt.Errorf("第 %d 次自动重新登录失败", attempt) } log.Info("自动重新登录成功", zap.String("action", action), zap.String("host", wk.Host), zap.String("username", wk.Username), zap.Int("attempt", attempt), ) return nil } func (wk *WK) removeBoundSession(action string, cause error) { if wk == nil || wk.sessionManager == nil || wk.sessionID == "" { return } log.Warn("自动重新登录失败,删除失效会话", zap.String("action", action), zap.String("session_id", wk.sessionID), zap.String("host", wk.Host), zap.String("username", wk.Username), zap.Error(cause), ) wk.sessionManager.Del(wk.sessionID) } func withAutoRelogin[T any](wk *WK, action string, fn func() (T, error)) (T, error) { result, err := fn() if !errors.Is(err, ErrLoginTimeout) { return result, err } lastErr := err for attempt := 1; attempt <= maxAutoReloginRetries; attempt++ { reloginErr := wk.reloginForExpiredSession(action, attempt) if reloginErr != nil { lastErr = reloginErr if errors.Is(reloginErr, ErrReloginSkipped) { break } continue } result, err = fn() if !errors.Is(err, ErrLoginTimeout) { return result, err } lastErr = err } wk.removeBoundSession(action, lastErr) var zero T if lastErr == nil { lastErr = ErrLoginTimeout } return zero, fmt.Errorf("%w: %s 自动重新登录 %d 次后仍未恢复: %v", ErrSessionRemoved, action, maxAutoReloginRetries, lastErr) }