540 lines
16 KiB
Go
540 lines
16 KiB
Go
package xuexitong
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"likedream-goframe/internal/application/xuexitong/entity"
|
||
"likedream-goframe/internal/framework/logger"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/PuerkitoBio/goquery"
|
||
"github.com/avast/retry-go/v4"
|
||
"go.uber.org/zap"
|
||
"resty.dev/v3"
|
||
)
|
||
|
||
// attachmentDetectWork 从附件设置中检测并填充作业信息
|
||
func (c *XuexitongCourse) attachmentDetectWork(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
|
||
for _, att := range attachment.Attachments {
|
||
property, ok := att["property"].(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
workId, _ := property["workid"].(string)
|
||
if workId != p.WorkID {
|
||
continue
|
||
}
|
||
|
||
if enc, ok := att["enc"].(string); ok {
|
||
p.Enc = enc
|
||
}
|
||
if title, ok := property["title"].(string); ok {
|
||
p.Title = title
|
||
}
|
||
break
|
||
}
|
||
|
||
if defaults := attachment.Defaults; defaults != nil {
|
||
if ktoken, ok := defaults["ktoken"].(string); ok {
|
||
p.KToken = ktoken
|
||
}
|
||
if userid, ok := defaults["userid"].(string); ok {
|
||
p.PUID = userid
|
||
}
|
||
}
|
||
}
|
||
|
||
// AttachmentsDetectionWork 从附件设置中检测并填充作业信息
|
||
func (c *XuexitongCourse) AttachmentsDetectionWork(attachment *entity.AttachmentSetting, p *entity.PointWorkDto) error {
|
||
if attachment == nil {
|
||
logger.Logger.Warn("attachment 为空")
|
||
return nil
|
||
}
|
||
|
||
logger.Logger.Debug("AttachmentsDetectionWork 开始",
|
||
zap.Int("attachmentsCount", len(attachment.Attachments)),
|
||
zap.String("targetWorkID", p.WorkID))
|
||
|
||
foundMatch := false
|
||
for i, att := range attachment.Attachments {
|
||
property, ok := att["property"].(map[string]interface{})
|
||
if !ok {
|
||
logger.Logger.Debug("附件 property 不存在", zap.Int("index", i))
|
||
continue
|
||
}
|
||
workId, _ := property["workid"].(string)
|
||
logger.Logger.Debug("检查附件",
|
||
zap.Int("index", i),
|
||
zap.String("workId", workId),
|
||
zap.String("targetWorkID", p.WorkID),
|
||
zap.Bool("match", workId == p.WorkID))
|
||
|
||
if workId != p.WorkID {
|
||
continue
|
||
}
|
||
|
||
foundMatch = true
|
||
if enc, ok := att["enc"].(string); ok {
|
||
p.Enc = enc
|
||
logger.Logger.Debug("从附件获取 enc", zap.String("enc", enc))
|
||
}
|
||
if title, ok := property["title"].(string); ok {
|
||
p.Title = title
|
||
}
|
||
break
|
||
}
|
||
|
||
if !foundMatch {
|
||
logger.Logger.Warn("未找到匹配的作业附件", zap.String("workID", p.WorkID))
|
||
}
|
||
|
||
if defaults := attachment.Defaults; defaults != nil {
|
||
logger.Logger.Debug("defaults 内容", zap.Any("defaults", defaults))
|
||
if ktoken, ok := defaults["ktoken"].(string); ok {
|
||
p.KToken = ktoken
|
||
logger.Logger.Debug("从 defaults 获取 ktoken", zap.String("ktoken", ktoken))
|
||
} else {
|
||
logger.Logger.Warn("defaults 中没有 ktoken")
|
||
}
|
||
if userid, ok := defaults["userid"].(string); ok {
|
||
p.PUID = userid
|
||
logger.Logger.Debug("从 defaults 获取 userid", zap.String("userid", userid))
|
||
} else {
|
||
logger.Logger.Warn("defaults 中没有 userid")
|
||
}
|
||
// 检查 defaults 中是否有 cpi,如果有则使用
|
||
if cpi, ok := defaults["cpi"]; ok {
|
||
switch v := cpi.(type) {
|
||
case string:
|
||
p.Cpi = v
|
||
logger.Logger.Debug("从 defaults 获取 cpi (string)", zap.String("cpi", v))
|
||
case float64:
|
||
p.Cpi = strconv.FormatInt(int64(v), 10)
|
||
logger.Logger.Debug("从 defaults 获取 cpi (float64)", zap.String("cpi", p.Cpi))
|
||
}
|
||
}
|
||
} else {
|
||
logger.Logger.Warn("attachment.Defaults 为空")
|
||
}
|
||
|
||
logger.Logger.Debug("AttachmentsDetectionWork 完成",
|
||
zap.String("PUID", p.PUID),
|
||
zap.String("KToken", p.KToken),
|
||
zap.String("Enc", p.Enc))
|
||
|
||
return nil
|
||
}
|
||
|
||
// AutoCompleteWorkByPoint 自动完成作业任务点(使用统一的 ChapterPoint)
|
||
func (c *XuexitongCourse) AutoCompleteWorkByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint, aiConfig *AIConfig, autoSubmit bool) error {
|
||
if p.IsPassed {
|
||
logger.Logger.Info("作业已完成", zap.String("jobID", p.JobID), zap.String("title", p.Title))
|
||
return nil
|
||
}
|
||
|
||
// 如果没有AI配置,跳过
|
||
if aiConfig == nil {
|
||
logger.Logger.Warn("未配置AI,跳过作业", zap.String("title", p.Title))
|
||
return nil
|
||
}
|
||
|
||
// 转换为 PointWorkDto
|
||
workDto := &entity.PointWorkDto{
|
||
CardIndex: p.CardIndex,
|
||
CourseID: p.CourseID,
|
||
ClassID: p.ClassID,
|
||
KnowledgeID: p.KnowledgeID,
|
||
Cpi: p.Cpi,
|
||
WorkID: p.WorkID,
|
||
SchoolID: p.SchoolID,
|
||
JobID: p.JobID,
|
||
PUID: p.PUID,
|
||
KToken: p.KToken,
|
||
Enc: p.Enc,
|
||
Title: p.Title,
|
||
IsPassed: p.IsPassed,
|
||
}
|
||
|
||
// 关键修复:获取 AttachmentSetting 来填充 PUID、KToken、Enc 等参数
|
||
attachment, enc, err := c.PageMobileChapterCard(ctx, course, p.KnowledgeID, p.CardIndex)
|
||
if err != nil {
|
||
logger.Logger.Error("获取章节卡片失败", zap.String("title", p.Title), zap.Error(err))
|
||
return err
|
||
}
|
||
|
||
if attachment != nil {
|
||
// 填充作业参数
|
||
if err := c.AttachmentsDetectionWork(attachment, workDto); err != nil {
|
||
logger.Logger.Error("填充作业参数失败", zap.String("title", p.Title), zap.Error(err))
|
||
return err
|
||
}
|
||
// 只有当附件中没有获取到 enc 时,才使用从页面获取的 enc
|
||
if workDto.Enc == "" && enc != "" {
|
||
workDto.Enc = enc
|
||
logger.Logger.Debug("使用页面 enc", zap.String("enc", enc))
|
||
}
|
||
}
|
||
|
||
logger.Logger.Debug("作业参数",
|
||
zap.String("workID", workDto.WorkID),
|
||
zap.String("PUID", workDto.PUID),
|
||
zap.String("KToken", workDto.KToken),
|
||
zap.String("Enc", workDto.Enc),
|
||
zap.String("Cpi", workDto.Cpi))
|
||
|
||
// 使用AI自动完成作业
|
||
result, err := c.AutoCompleteWorkWithAI(ctx, workDto, *aiConfig, autoSubmit)
|
||
if err != nil {
|
||
logger.Logger.Error("AI答题失败", zap.String("title", p.Title), zap.Error(err))
|
||
return err
|
||
}
|
||
|
||
logger.Logger.Info("AI答题完成", zap.String("title", p.Title), zap.String("result", result))
|
||
return nil
|
||
}
|
||
|
||
// FetchWorkQuestions 获取作业题目
|
||
func (c *XuexitongCourse) FetchWorkQuestions(ctx context.Context, p *entity.PointWorkDto) (*entity.WorkDto, error) {
|
||
c.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||
defer func() {
|
||
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||
}()
|
||
params := url.Values{}
|
||
params.Add("courseid", p.CourseID)
|
||
workID := p.WorkID
|
||
if p.SchoolID != "" && p.SchoolID != "0" {
|
||
workID = p.SchoolID + "-" + p.WorkID
|
||
}
|
||
params.Add("workid", workID)
|
||
params.Add("jobid", p.JobID)
|
||
params.Add("needRedirect", "true")
|
||
params.Add("knowledgeid", strconv.Itoa(p.KnowledgeID))
|
||
// 关键修复:使用 p.PUID 而不是 c.userID
|
||
params.Add("userid", p.PUID)
|
||
params.Add("ut", "s")
|
||
params.Add("clazzId", p.ClassID)
|
||
params.Add("cpi", p.Cpi)
|
||
params.Add("ktoken", p.KToken)
|
||
params.Add("enc", p.Enc)
|
||
|
||
logger.Logger.Debug("FetchWorkQuestions 请求参数",
|
||
zap.String("courseid", p.CourseID),
|
||
zap.String("workid", workID),
|
||
zap.String("jobid", p.JobID),
|
||
zap.Int("knowledgeid", p.KnowledgeID),
|
||
zap.String("userid", p.PUID),
|
||
zap.String("clazzId", p.ClassID),
|
||
zap.String("cpi", p.Cpi),
|
||
zap.String("ktoken", p.KToken),
|
||
zap.String("enc", p.Enc))
|
||
|
||
var body string
|
||
requestURL := ApiWorkFetch + "?" + params.Encode()
|
||
logger.Logger.Debug("FetchWorkQuestions 请求URL", zap.String("url", requestURL))
|
||
|
||
err := retry.Do(func() error {
|
||
resp, err := c.client.R().SetHeaders(map[string]string{
|
||
"User-Agent": GetUA("mobile"),
|
||
"Sec-Ch-Ua-Platform": "Windows",
|
||
"Accept": "*/*",
|
||
"Host": "mooc1-api.chaoxing.com",
|
||
"Connection": "keep-alive",
|
||
"Accept-Language": "zh_CN",
|
||
}).Get(requestURL)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body = resp.String()
|
||
logger.Logger.Debug("FetchWorkQuestions 响应", zap.String("body", body[:min(len(body), 500)]))
|
||
return nil
|
||
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
workDto, err := c.parseWorkHTML(body, p)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return workDto, nil
|
||
}
|
||
|
||
// parseWorkHTML 解析作业HTML页面,提取题目信息(使用 goquery,与 pkg/yatori-go-core 保持一致)
|
||
func (c *XuexitongCourse) parseWorkHTML(htmlContent string, p *entity.PointWorkDto) (*entity.WorkDto, error) {
|
||
workDto := &entity.WorkDto{
|
||
WorkId: p.WorkID,
|
||
ClassId: p.ClassID,
|
||
CourseId: p.CourseID,
|
||
KnowledgeId: strconv.Itoa(p.KnowledgeID),
|
||
Cpi: p.Cpi,
|
||
JobId: p.JobID,
|
||
UserId: p.PUID,
|
||
}
|
||
|
||
// 使用 goquery 解析 HTML
|
||
doc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(htmlContent)))
|
||
if err != nil {
|
||
logger.Logger.Error("解析作业HTML失败", zap.Error(err))
|
||
return nil, err
|
||
}
|
||
|
||
// 解析作业信息(与 pkg/yatori-go-core/utils/XueXiTParseWorkUtils.go 中的 ParseWorkInform 一致)
|
||
findList := []string{"userId", "courseId", "classId", "api", "workAnswerId",
|
||
"answerId", "totalQuestionNum", "fullScore", "knowledgeid", "oldSchoolId",
|
||
"oldWorkId", "jobid", "workRelationId", "enc", "enc_work", "cpi", "workId"}
|
||
for _, find := range findList {
|
||
doc.Find("input#" + find).Each(func(i int, s *goquery.Selection) {
|
||
val, exists := s.Attr("value")
|
||
if exists {
|
||
switch find {
|
||
case "workAnswerId":
|
||
workDto.WorkAnswerId = val
|
||
case "answerId":
|
||
workDto.AnswerId = val
|
||
case "totalQuestionNum":
|
||
workDto.TotalQuestionNum = val
|
||
case "fullScore":
|
||
workDto.FullScore = val
|
||
case "oldWorkId":
|
||
workDto.OldWorkId = val
|
||
case "oldSchoolId":
|
||
workDto.OldSchoolId = val
|
||
case "api":
|
||
workDto.Api = val
|
||
case "workRelationId":
|
||
workDto.WorkRelationId = val
|
||
case "enc_work":
|
||
workDto.EncWork = val
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 截取试卷标题
|
||
doc.Find(".chapter-title").Each(func(i int, s *goquery.Selection) {
|
||
val, exists := s.Attr("workname")
|
||
if exists {
|
||
workDto.Title = val
|
||
}
|
||
})
|
||
|
||
// 解析题目(与 pkg/yatori-go-core/aggregation/xuexitong/XueXiTongCardAction.go 中的 ParseWorkQuestionAction 一致)
|
||
questionNodes := doc.Find("div.Py-mian1")
|
||
questionNodes.Each(func(i int, questionNode *goquery.Selection) {
|
||
// 获取题目ID
|
||
qid, _ := questionNode.Attr("data")
|
||
|
||
// 获取题目HTML
|
||
questionHTML, _ := questionNode.Html()
|
||
qdoc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(questionHTML)))
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
// 提取题目类型
|
||
quesTypeText := qdoc.Find(".Py-m1-title .quesType").Text()
|
||
quesType := extractQuestionType(quesTypeText)
|
||
|
||
// 提取题目文本
|
||
var quesTextBuilder strings.Builder
|
||
qdoc.Find(".Py-m1-title").Contents().Each(func(i int, n *goquery.Selection) {
|
||
quesTextBuilder.WriteString(n.Text())
|
||
})
|
||
quesText := cleanText(quesTextBuilder.String())
|
||
|
||
question := entity.WorkQuestion{
|
||
Qid: qid,
|
||
Text: quesText,
|
||
Options: make(map[string]string),
|
||
}
|
||
|
||
// 根据题型解析选项
|
||
switch quesType {
|
||
case "单选题":
|
||
question.Type = entity.QuestionTypeSingleChoice
|
||
qdoc.Find(".answerList.singleChoice li").Each(func(i int, s *goquery.Selection) {
|
||
optionLetter := s.Find("em.choose-opt").Text()
|
||
ccContent := s.Find("cc").Text()
|
||
if ccContent == "" {
|
||
img, exists := s.Find("cc img").Attr("src")
|
||
if exists {
|
||
ccContent = "Image: " + img
|
||
}
|
||
}
|
||
question.Options[optionLetter] = strings.TrimSpace(ccContent)
|
||
})
|
||
|
||
case "多选题":
|
||
question.Type = entity.QuestionTypeMultipleChoice
|
||
qdoc.Find(".answerList.multiChoice li").Each(func(i int, s *goquery.Selection) {
|
||
optionLetter := s.Find("em.choose-opt").Text()
|
||
ccContent := s.Find("cc").Text()
|
||
if ccContent == "" {
|
||
img, exists := s.Find("cc img").Attr("src")
|
||
if exists {
|
||
ccContent = "Image: " + img
|
||
}
|
||
}
|
||
question.Options[optionLetter] = strings.TrimSpace(ccContent)
|
||
})
|
||
|
||
case "判断题":
|
||
question.Type = entity.QuestionTypeTrueFalse
|
||
qdoc.Find(".answerList.panduan li").Each(func(i int, s *goquery.Selection) {
|
||
optionLetter := s.Find("em").Text()
|
||
pContent := s.Find("p").Text()
|
||
question.Options[optionLetter] = strings.TrimSpace(pContent)
|
||
})
|
||
|
||
case "填空题":
|
||
question.Type = entity.QuestionTypeFillBlank
|
||
// 统计填空数量
|
||
blankCount := 0
|
||
qdoc.Find("ul.blankList2").Each(func(i int, selection *goquery.Selection) {
|
||
blankCount++
|
||
})
|
||
question.BlankCount = blankCount
|
||
if question.BlankCount == 0 {
|
||
question.BlankCount = 1
|
||
}
|
||
|
||
case "简答题":
|
||
question.Type = entity.QuestionTypeShortAnswer
|
||
}
|
||
|
||
workDto.Questions = append(workDto.Questions, question)
|
||
})
|
||
|
||
logger.Logger.Debug("解析作业完成",
|
||
zap.String("title", workDto.Title),
|
||
zap.Int("questionCount", len(workDto.Questions)))
|
||
|
||
return workDto, nil
|
||
}
|
||
|
||
// extractQuestionType 从文本中提取题目类型
|
||
func extractQuestionType(text string) string {
|
||
start := strings.Index(text, "[")
|
||
end := strings.Index(text, "]")
|
||
if start != -1 && end != -1 && end > start {
|
||
return strings.TrimSpace(text[start+1 : end])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// cleanText 清理文本中的多余空白
|
||
func cleanText(text string) string {
|
||
text = strings.TrimSpace(text)
|
||
// 移除多余的空白字符
|
||
text = strings.Join(strings.Fields(text), " ")
|
||
return text
|
||
}
|
||
|
||
// SubmitWorkAnswers 提交作业答案
|
||
func (c *XuexitongCourse) SubmitWorkAnswers(ctx context.Context, workDto *entity.WorkDto, isSave bool) (string, error) {
|
||
formData := url.Values{}
|
||
pyFlag := ""
|
||
if isSave {
|
||
pyFlag = "1"
|
||
}
|
||
formData.Set("pyFlag", pyFlag)
|
||
formData.Set("courseId", workDto.CourseId)
|
||
formData.Set("classId", workDto.ClassId)
|
||
formData.Set("api", workDto.Api)
|
||
formData.Set("workAnswerId", workDto.WorkAnswerId)
|
||
formData.Set("answerId", workDto.AnswerId)
|
||
formData.Set("totalQuestionNum", workDto.TotalQuestionNum)
|
||
formData.Set("fullScore", workDto.FullScore)
|
||
formData.Set("knowledgeid", workDto.KnowledgeId)
|
||
formData.Set("oldSchoolId", workDto.OldSchoolId)
|
||
formData.Set("oldWorkId", workDto.OldWorkId)
|
||
formData.Set("jobid", workDto.JobId)
|
||
formData.Set("workRelationId", workDto.WorkRelationId)
|
||
formData.Set("enc", "")
|
||
formData.Set("enc_work", workDto.EncWork)
|
||
formData.Set("userId", workDto.UserId)
|
||
formData.Set("cpi", workDto.Cpi)
|
||
formData.Set("workTimesEnc", "")
|
||
formData.Set("randomOptions", "false")
|
||
formData.Set("isAccessibleCustomFid", "0")
|
||
|
||
answerwqbid := ""
|
||
for _, q := range workDto.Questions {
|
||
if q.Qid != "" {
|
||
answerwqbid += q.Qid + ","
|
||
}
|
||
|
||
switch q.Type {
|
||
case entity.QuestionTypeSingleChoice:
|
||
answer := strings.Join(q.Answers, "")
|
||
formData.Set("answer"+q.Qid, answer)
|
||
formData.Set("answertype"+q.Qid, "0")
|
||
|
||
case entity.QuestionTypeMultipleChoice:
|
||
answer := strings.Join(q.Answers, "")
|
||
formData.Set("answer"+q.Qid, answer)
|
||
formData.Set("answertype"+q.Qid, "1")
|
||
|
||
case entity.QuestionTypeFillBlank:
|
||
for key, val := range q.BlankAnswer {
|
||
re := regexp.MustCompile(`\d+`)
|
||
numbers := re.FindAllString(key, -1)
|
||
if len(numbers) > 0 {
|
||
formData.Set("answer"+q.Qid+numbers[0], val)
|
||
}
|
||
}
|
||
formData.Set("tiankongsize"+q.Qid, strconv.Itoa(q.BlankCount))
|
||
formData.Set("answertype"+q.Qid, "2")
|
||
|
||
case entity.QuestionTypeTrueFalse:
|
||
answer := ""
|
||
for _, a := range q.Answers {
|
||
if a == "正确" {
|
||
answer = "true"
|
||
} else if a == "错误" {
|
||
answer = "false"
|
||
} else {
|
||
answer = a
|
||
}
|
||
}
|
||
formData.Set("answer"+q.Qid, answer)
|
||
formData.Set("answertype"+q.Qid, "3")
|
||
|
||
case entity.QuestionTypeShortAnswer:
|
||
answer := strings.Join(q.Answers, "")
|
||
formData.Set("answer"+q.Qid, answer)
|
||
formData.Set("answertype"+q.Qid, "4")
|
||
}
|
||
}
|
||
formData.Set("answerwqbid", answerwqbid)
|
||
|
||
urlStr := fmt.Sprintf("%s?_classId=%s&courseid=%s&token=%s&totalQuestionNum=%s&ua=pc&formType=post&saveStatus=1&version=1&tempsave=1",
|
||
ApiWorkSubmit, workDto.ClassId, workDto.CourseId, workDto.EncWork, workDto.TotalQuestionNum)
|
||
|
||
var body string
|
||
err := retry.Do(func() error {
|
||
resp, err := c.client.R().SetHeaders(map[string]string{
|
||
"User-Agent": GetUA("mobile"),
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
}).SetBody(formData.Encode()).Post(urlStr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body = resp.String()
|
||
return nil
|
||
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return body, nil
|
||
}
|