diff --git a/docs/api/ai_config_api.md b/docs/api/ai_config_api.md new file mode 100644 index 0000000..b97b78a --- /dev/null +++ b/docs/api/ai_config_api.md @@ -0,0 +1,289 @@ +# AI 答题配置接口文档 + +## 概述 + +AI 答题功能用于在刷课过程中自动完成作业任务点。系统支持多种 AI 服务商,管理员可以通过配置接口设置 AI 参数。 + +核心代码位于 `internal/application/quesbank` 包,提供通用的题库答题能力,可被不同平台(学习通、智慧教育等)复用。 + +## 接口列表 + +### 1. 获取支持的 AI 类型列表 + +获取系统支持的所有 AI 类型及其说明。 + +**请求** + +``` +GET /v1/system/ai-config/types +``` + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "types": [ + { + "type": "chatglm", + "name": "智谱AI (ChatGLM)", + "description": "智谱AI大模型,支持glm-4等模型", + "needUrl": false + }, + { + "type": "xinghuo", + "name": "讯飞星火", + "description": "讯飞星火认知大模型", + "needUrl": false + }, + { + "type": "tongyi", + "name": "通义千问", + "description": "阿里云通义千问大模型", + "needUrl": false + }, + { + "type": "doubao", + "name": "豆包", + "description": "字节跳动豆包大模型", + "needUrl": false + }, + { + "type": "openai", + "name": "OpenAI", + "description": "OpenAI GPT系列模型", + "needUrl": false + }, + { + "type": "other", + "name": "其他 (兼容OpenAI格式)", + "description": "支持任何兼容OpenAI API格式的服务", + "needUrl": true + } + ] + } +} +``` + +--- + +### 2. 获取当前 AI 配置 + +获取当前系统配置的 AI 参数(API密钥会脱敏显示)。 + +**请求** + +``` +GET /v1/system/ai-config +``` + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "type": "chatglm", + "url": "", + "model": "glm-4", + "apiKey": "sk-1****************************xyz" + } +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| type | string | AI类型,可选值见类型列表 | +| url | string | API地址,仅 `other` 类型需要 | +| model | string | 模型名称 | +| apiKey | string | API密钥(脱敏显示) | + +--- + +### 3. 更新 AI 配置 + +更新系统的 AI 配置参数。 + +**请求** + +``` +PUT /v1/system/ai-config +``` + +**权限** + +需要 `sys:ai:edit` 权限 + +**请求体** + +```json +{ + "type": "chatglm", + "url": "", + "model": "glm-4", + "apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} +``` + +**参数说明** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| type | string | 是 | AI类型,可选值:`chatglm`, `xinghuo`, `tongyi`, `doubao`, `openai`, `other` | +| url | string | 否 | API地址,当 type 为 `other` 时必填 | +| model | string | 否 | 模型名称,不填则使用默认模型 | +| apiKey | string | 是 | API密钥 | + +**各 AI 类型默认模型** + +| AI类型 | 默认模型 | +|--------|----------| +| chatglm | glm-4 | +| xinghuo | generalv3.5 | +| tongyi | qwen-plus | +| doubao | 需指定 | +| openai | gpt-3.5-turbo | +| other | 需指定 | + +**响应** + +```json +{ + "code": 0, + "message": "AI配置更新成功" +} +``` + +--- + +### 4. 测试 AI 配置 + +测试 AI 配置是否可用,发送测试消息验证连接。 + +**请求** + +``` +POST /v1/system/ai-config/test +``` + +**权限** + +需要 `sys:ai:edit` 权限 + +**请求体** + +```json +{ + "type": "chatglm", + "url": "", + "model": "glm-4", + "apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +} +``` + +**响应** + +成功: +```json +{ + "code": 0, + "message": "success", + "data": { + "success": true, + "message": "AI连接测试成功,响应: 测试成功..." + } +} +``` + +失败: +```json +{ + "code": 0, + "message": "success", + "data": { + "success": false, + "message": "AI连接测试失败: failed to execute request: ..." + } +} +``` + +--- + +## AI 服务商配置指南 + +### 智谱AI (ChatGLM) + +1. 访问 [智谱AI开放平台](https://open.bigmodel.cn/) +2. 注册并创建 API Key +3. 配置参数: + - type: `chatglm` + - model: `glm-4` 或 `glm-3-turbo` + - apiKey: 你的 API Key + +### 讯飞星火 + +1. 访问 [讯飞开放平台](https://www.xfyun.cn/) +2. 创建应用并获取 API Key +3. 配置参数: + - type: `xinghuo` + - model: `generalv3.5` 或其他版本 + - apiKey: 你的 API Key + +### 通义千问 + +1. 访问 [阿里云百炼](https://dashscope.aliyun.com/) +2. 开通服务并获取 API Key +3. 配置参数: + - type: `tongyi` + - model: `qwen-plus` 或 `qwen-turbo` + - apiKey: 你的 API Key + +### 豆包 + +1. 访问 [火山引擎](https://www.volcengine.com/) +2. 开通豆包大模型服务 +3. 配置参数: + - type: `doubao` + - model: 你的模型端点 ID + - apiKey: 你的 API Key + +### OpenAI + +1. 访问 [OpenAI Platform](https://platform.openai.com/) +2. 创建 API Key +3. 配置参数: + - type: `openai` + - model: `gpt-3.5-turbo` 或 `gpt-4` + - apiKey: 你的 API Key + +### 其他兼容服务 + +支持任何兼容 OpenAI API 格式的服务(如 DeepSeek、Moonshot 等)。 + +配置参数: +- type: `other` +- url: API 地址(如 `https://api.deepseek.com/v1/chat/completions`) +- model: 模型名称 +- apiKey: 你的 API Key + +--- + +## 使用说明 + +1. 配置 AI 后,系统在执行刷课任务时会自动使用 AI 完成作业任务点 +2. 作业答案默认仅保存不提交,需要用户手动确认后提交 +3. 如果未配置 AI,作业任务点将被跳过 +4. 建议先使用测试接口验证配置是否正确 + +## 错误码 + +| 错误码 | 说明 | +|--------|------| +| 400 | 参数错误或配置不完整 | +| 403 | 无权限操作 | +| 500 | 服务器内部错误 | diff --git a/go.mod b/go.mod index b5e7c33..bc23a99 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.43.0 + golang.org/x/net v0.45.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 resty.dev/v3 v3.0.0-beta.3 @@ -106,7 +107,6 @@ require ( golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect golang.org/x/image v0.29.0 // indirect golang.org/x/mod v0.28.0 // indirect - golang.org/x/net v0.45.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect diff --git a/internal/api/server.go b/internal/api/server.go index 5d952ab..b047d73 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -44,6 +44,7 @@ func StartServer(ctx context.Context) { systemGroup.Bind(menu.NewV1()) systemGroup.Bind(user.NewUserCtrlV1()) systemGroup.Bind(config.NewConfigCtrlV1()) + systemGroup.Bind(config.NewAIConfigCtrlV1()) //systemGroup.Bind(agent.NewV1()) // 用户信息路由 userGroup := group.Group("/user").Middleware(middleware.AuthHandler, middleware.PermissionHandler) diff --git a/internal/api/v1/ai_config_req.go b/internal/api/v1/ai_config_req.go new file mode 100644 index 0000000..1f3a199 --- /dev/null +++ b/internal/api/v1/ai_config_req.go @@ -0,0 +1,60 @@ +package v1 + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +// AIConfigGetReq 获取AI配置请求 +type AIConfigGetReq struct { + g.Meta `path:"/ai-config" method:"get" tags:"AIConfig" summary:"获取AI配置"` +} + +// AIConfigGetResp AI配置响应 +type AIConfigGetResp struct { + Type string `json:"type"` // AI类型: chatglm, xinghuo, tongyi, doubao, openai, other + URL string `json:"url"` // API地址(other类型必填) + Model string `json:"model"` // 模型名称 + APIKey string `json:"apiKey"` // API密钥(返回时脱敏) +} + +// AIConfigUpdateReq 更新AI配置请求 +type AIConfigUpdateReq struct { + g.Meta `path:"/ai-config" method:"put" tags:"AIConfig" summary:"更新AI配置" permission:"sys:ai:edit"` + Type string `json:"type" v:"required|in:chatglm,xinghuo,tongyi,doubao,openai,other#AI类型不能为空|AI类型不合法"` + URL string `json:"url"` // API地址(other类型必填) + Model string `json:"model"` // 模型名称 + APIKey string `json:"apiKey" v:"required#API密钥不能为空"` // API密钥 +} + +// AIConfigTestReq 测试AI配置请求 +type AIConfigTestReq struct { + g.Meta `path:"/ai-config/test" method:"post" tags:"AIConfig" summary:"测试AI配置" permission:"sys:ai:edit"` + Type string `json:"type" v:"required|in:chatglm,xinghuo,tongyi,doubao,openai,other#AI类型不能为空|AI类型不合法"` + URL string `json:"url"` + Model string `json:"model"` + APIKey string `json:"apiKey" v:"required#API密钥不能为空"` +} + +// AIConfigTestResp 测试AI配置响应 +type AIConfigTestResp struct { + Success bool `json:"success"` // 是否成功 + Message string `json:"message"` // 测试结果消息 +} + +// AITypeListReq 获取支持的AI类型列表请求 +type AITypeListReq struct { + g.Meta `path:"/ai-config/types" method:"get" tags:"AIConfig" summary:"获取支持的AI类型列表"` +} + +// AITypeItem AI类型项 +type AITypeItem struct { + Type string `json:"type"` // 类型标识 + Name string `json:"name"` // 显示名称 + Description string `json:"description"` // 描述 + NeedURL bool `json:"needUrl"` // 是否需要自定义URL +} + +// AITypeListResp AI类型列表响应 +type AITypeListResp struct { + Types []AITypeItem `json:"types"` +} diff --git a/internal/application/quesbank/ai_config.go b/internal/application/quesbank/ai_config.go new file mode 100644 index 0000000..7fa65a8 --- /dev/null +++ b/internal/application/quesbank/ai_config.go @@ -0,0 +1,32 @@ +package quesbank + +// AIType AI类型 +type AIType string + +const ( + AITypeChatGLM AIType = "chatglm" // 智谱AI + AITypeXingHuo AIType = "xinghuo" // 讯飞星火 + AITypeTongYi AIType = "tongyi" // 通义千问 + AITypeDouBao AIType = "doubao" // 豆包 + AITypeOpenAI AIType = "openai" // OpenAI + AITypeOther AIType = "other" // 其他兼容OpenAI格式的API +) + +// AIConfig AI配置 +type AIConfig struct { + Type AIType `json:"type"` // AI类型 + URL string `json:"url"` // API地址(Other类型必填) + Model string `json:"model"` // 模型名称 + APIKey string `json:"apiKey"` // API密钥 +} + +// AIChatMessage 聊天消息 +type AIChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// AIChatMessages 聊天消息列表 +type AIChatMessages struct { + Messages []AIChatMessage `json:"messages"` +} diff --git a/internal/application/quesbank/ai_service.go b/internal/application/quesbank/ai_service.go new file mode 100644 index 0000000..da2a2f9 --- /dev/null +++ b/internal/application/quesbank/ai_service.go @@ -0,0 +1,173 @@ +package quesbank + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// AIAnswerService AI答题服务 +type AIAnswerService struct { + config AIConfig + mu sync.Mutex +} + +// NewAIAnswerService 创建AI答题服务 +func NewAIAnswerService(config AIConfig) *AIAnswerService { + return &AIAnswerService{ + config: config, + } +} + +// GetAnswer 获取AI答案 +func (s *AIAnswerService) GetAnswer(ctx context.Context, messages AIChatMessages) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + switch s.config.Type { + case AITypeChatGLM: + return s.chatGLMRequest(ctx, messages) + case AITypeXingHuo: + return s.xingHuoRequest(ctx, messages) + case AITypeTongYi: + return s.tongYiRequest(ctx, messages) + case AITypeDouBao: + return s.douBaoRequest(ctx, messages) + case AITypeOpenAI: + return s.openAIRequest(ctx, messages) + case AITypeOther: + return s.otherRequest(ctx, messages) + default: + return "", fmt.Errorf("unsupported AI type: %s", s.config.Type) + } +} + +// chatGLMRequest 智谱AI请求 +func (s *AIAnswerService) chatGLMRequest(ctx context.Context, messages AIChatMessages) (string, error) { + url := "https://open.bigmodel.cn/api/paas/v4/chat/completions" + model := s.config.Model + if model == "" { + model = "glm-4" + } + return s.doOpenAIStyleRequest(ctx, url, model, messages) +} + +// xingHuoRequest 讯飞星火请求 +func (s *AIAnswerService) xingHuoRequest(ctx context.Context, messages AIChatMessages) (string, error) { + url := "https://spark-api-open.xf-yun.com/v1/chat/completions" + model := s.config.Model + if model == "" { + model = "generalv3.5" + } + return s.doOpenAIStyleRequest(ctx, url, model, messages) +} + +// tongYiRequest 通义千问请求 +func (s *AIAnswerService) tongYiRequest(ctx context.Context, messages AIChatMessages) (string, error) { + url := "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + model := s.config.Model + if model == "" { + model = "qwen-plus" + } + return s.doOpenAIStyleRequest(ctx, url, model, messages) +} + +// douBaoRequest 豆包请求 +func (s *AIAnswerService) douBaoRequest(ctx context.Context, messages AIChatMessages) (string, error) { + url := "https://ark.cn-beijing.volces.com/api/v3/chat/completions" + return s.doOpenAIStyleRequest(ctx, url, s.config.Model, messages) +} + +// openAIRequest OpenAI请求 +func (s *AIAnswerService) openAIRequest(ctx context.Context, messages AIChatMessages) (string, error) { + url := "https://api.openai.com/v1/chat/completions" + model := s.config.Model + if model == "" { + model = "gpt-3.5-turbo" + } + return s.doOpenAIStyleRequest(ctx, url, model, messages) +} + +// otherRequest 其他兼容OpenAI格式的API请求 +func (s *AIAnswerService) otherRequest(ctx context.Context, messages AIChatMessages) (string, error) { + if s.config.URL == "" { + return "", errors.New("API URL is required for 'other' AI type") + } + return s.doOpenAIStyleRequest(ctx, s.config.URL, s.config.Model, messages) +} + +// doOpenAIStyleRequest 执行OpenAI风格的API请求 +func (s *AIAnswerService) doOpenAIStyleRequest(ctx context.Context, url, model string, messages AIChatMessages) (string, error) { + requestBody := map[string]interface{}{ + "model": model, + "messages": messages.Messages, + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %v", err) + } + + var lastErr error + for retry := 0; retry < 3; retry++ { + result, err := s.doHTTPRequest(ctx, url, jsonData) + if err == nil { + return result, nil + } + lastErr = err + time.Sleep(100 * time.Millisecond) + } + return "", lastErr +} + +// doHTTPRequest 执行HTTP请求 +func (s *AIAnswerService) doHTTPRequest(ctx context.Context, url string, jsonData []byte) (string, error) { + client := &http.Client{Timeout: 60 * time.Second} + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+s.config.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to execute request: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %v", err) + } + + var responseMap map[string]interface{} + if err := json.Unmarshal(body, &responseMap); err != nil { + return "", fmt.Errorf("failed to parse response: %v, body: %s", err, string(body)) + } + + choices, ok := responseMap["choices"].([]interface{}) + if !ok || len(choices) == 0 { + return "", fmt.Errorf("AI response not found, response: %s", string(body)) + } + + message, ok := choices[0].(map[string]interface{})["message"].(map[string]interface{}) + if !ok { + return "", errors.New("failed to parse message from response") + } + + content, ok := message["content"].(string) + if !ok { + return "", errors.New("content field missing in response") + } + + return content, nil +} diff --git a/internal/application/quesbank/answer_parser.go b/internal/application/quesbank/answer_parser.go new file mode 100644 index 0000000..7c411b8 --- /dev/null +++ b/internal/application/quesbank/answer_parser.go @@ -0,0 +1,97 @@ +package quesbank + +import ( + "encoding/json" + "strings" +) + +// ParseAIAnswer 解析AI答案 +func ParseAIAnswer(aiResponse string) ([]string, error) { + aiResponse = strings.TrimSpace(aiResponse) + + // 尝试直接解析JSON数组 + var answers []string + if err := json.Unmarshal([]byte(aiResponse), &answers); err == nil { + return answers, nil + } + + // 尝试从响应中提取JSON数组 + start := strings.Index(aiResponse, "[") + end := strings.LastIndex(aiResponse, "]") + if start != -1 && end != -1 && end > start { + jsonStr := aiResponse[start : end+1] + if err := json.Unmarshal([]byte(jsonStr), &answers); err == nil { + return answers, nil + } + } + + // 如果都失败,返回原始响应作为单个答案 + return []string{aiResponse}, nil +} + +// MatchAnswerToOption 将AI答案匹配到选项 +func MatchAnswerToOption(answer string, options map[string]string) string { + // 精确匹配 + for key, val := range options { + if val == answer || strings.TrimSpace(val) == strings.TrimSpace(answer) { + return key + } + } + + // 模糊匹配(包含关系) + for key, val := range options { + if strings.Contains(val, answer) || strings.Contains(answer, val) { + return key + } + } + + // 相似度匹配 + bestKey := "" + bestScore := 0.0 + for key, val := range options { + score := similarity(answer, val) + if score > bestScore { + bestScore = score + bestKey = key + } + } + if bestScore >= 0.5 { + return bestKey + } + + return "" +} + +// similarity 计算字符串相似度(简单实现) +func similarity(s1, s2 string) float64 { + if s1 == s2 { + return 1.0 + } + if len(s1) == 0 || len(s2) == 0 { + return 0.0 + } + + // 使用最长公共子序列计算相似度 + lcs := longestCommonSubsequence(s1, s2) + return float64(lcs) / float64(max(len(s1), len(s2))) +} + +// longestCommonSubsequence 计算最长公共子序列长度 +func longestCommonSubsequence(s1, s2 string) int { + m, n := len(s1), len(s2) + dp := make([][]int, m+1) + for i := range dp { + dp[i] = make([]int, n+1) + } + + for i := 1; i <= m; i++ { + for j := 1; j <= n; j++ { + if s1[i-1] == s2[j-1] { + dp[i][j] = dp[i-1][j-1] + 1 + } else { + dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + } + } + } + return dp[m][n] +} diff --git a/internal/application/quesbank/prompt.go b/internal/application/quesbank/prompt.go new file mode 100644 index 0000000..2831a69 --- /dev/null +++ b/internal/application/quesbank/prompt.go @@ -0,0 +1,151 @@ +package quesbank + +import ( + "fmt" + "strings" +) + +// BuildQuestionPrompt 构建题目提示词 +func BuildQuestionPrompt(question Question) AIChatMessages { + var messages []AIChatMessage + + switch question.Type { + case QuestionTypeSingleChoice: + messages = buildSingleChoicePrompt(question) + case QuestionTypeMultipleChoice: + messages = buildMultipleChoicePrompt(question) + case QuestionTypeTrueFalse: + messages = buildTrueFalsePrompt(question) + case QuestionTypeFillBlank: + messages = buildFillBlankPrompt(question) + case QuestionTypeShortAnswer: + messages = buildShortAnswerPrompt(question) + default: + messages = buildDefaultPrompt(question) + } + + return AIChatMessages{Messages: messages} +} + +// buildSingleChoicePrompt 构建单选题提示词 +func buildSingleChoicePrompt(q Question) []AIChatMessage { + context := buildQuestionContext(q) + return []AIChatMessage{ + {Role: "user", Content: "接下来你只需要回答选项对应内容即可...格式:[\"选项1\"]"}, + {Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"}, + {Role: "user", Content: singleChoiceExample()}, + {Role: "user", Content: fmt.Sprintf("题目类型:单选题\n题目内容:%s", context)}, + } +} + +// buildMultipleChoicePrompt 构建多选题提示词 +func buildMultipleChoicePrompt(q Question) []AIChatMessage { + context := buildQuestionContext(q) + return []AIChatMessage{ + {Role: "user", Content: "接下来你只需要回答选项对应内容即可...格式:[\"选项1\",\"选项2\"]"}, + {Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"}, + {Role: "user", Content: multipleChoiceExample()}, + {Role: "user", Content: fmt.Sprintf("题目类型:多选题\n题目内容:%s", context)}, + } +} + +// buildTrueFalsePrompt 构建判断题提示词 +func buildTrueFalsePrompt(q Question) []AIChatMessage { + context := buildQuestionContext(q) + return []AIChatMessage{ + {Role: "user", Content: "接下来你只需要回答\"正确\"或者\"错误\"即可...格式:[\"正确\"]"}, + {Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"}, + {Role: "user", Content: trueFalseExample()}, + {Role: "user", Content: fmt.Sprintf("题目类型:判断题\n题目内容:%s", context)}, + } +} + +// buildFillBlankPrompt 构建填空题提示词 +func buildFillBlankPrompt(q Question) []AIChatMessage { + return []AIChatMessage{ + {Role: "user", Content: "其中,\"( )\"相关字样的地方是你需要填写答案的地方...格式:[\"答案1\",\"答案2\"]"}, + {Role: "user", Content: "就算你不知道填什么也随机填...无需回答任何解释!!!"}, + {Role: "user", Content: fillBlankExample()}, + {Role: "user", Content: fmt.Sprintf("题目类型:填空题\n题目内容:%s", q.Text)}, + } +} + +// buildShortAnswerPrompt 构建简答题提示词 +func buildShortAnswerPrompt(q Question) []AIChatMessage { + return []AIChatMessage{ + {Role: "user", Content: "这是一个简答题...格式:[\"答案\"],注意不要拆分答案!!!"}, + {Role: "user", Content: "就算你不知道答什么也随机答...无需回答任何解释!!!"}, + {Role: "user", Content: shortAnswerExample()}, + {Role: "user", Content: fmt.Sprintf("题目类型:简答题\n题目内容:%s", q.Text)}, + } +} + +// buildDefaultPrompt 构建默认提示词 +func buildDefaultPrompt(q Question) []AIChatMessage { + context := buildQuestionContext(q) + return []AIChatMessage{ + {Role: "user", Content: "请回答以下题目,格式:[\"答案\"]"}, + {Role: "user", Content: context}, + } +} + +// buildQuestionContext 构建题目上下文 +func buildQuestionContext(q Question) string { + var sb strings.Builder + sb.WriteString(q.Text) + sb.WriteString("\n") + for key, val := range q.Options { + sb.WriteString(fmt.Sprintf("%s. %s\n", key, val)) + } + return sb.String() +} + +func singleChoiceExample() string { + return `比如: +题目类型:单选题 +题目内容:新中国是什么时候成立的 +A. 1949年10月5日 +B. 1949年10月1日 +C. 1949年09月1日 +D. 2002年10月1日 + +那么你应该回答选项B的内容:"["1949年10月1日"]"` +} + +func multipleChoiceExample() string { + return `比如: +题目类型:多选题 +题目内容:马克思关于资本积累的学说是剩余价值理论的重要组成部分... +A. 资本主义扩大再生产的源泉 +B. 资本有机构成呈现不断降低趋势的根本原因 +C. 社会财富占有两极分化的重要原因 +D. 资本主义社会失业现象产生的根源 + +那么你应该回答选项A、C、D的内容:"["资本主义扩大再生产的源泉","社会财富占有两极分化的重要原因","资本主义社会失业现象产生的根源"]"` +} + +func trueFalseExample() string { + return `比如: +题目类型:判断题 +题目内容:新中国是1949年10月1日成立的吗? +A. 正确 +B. 错误 + +那么你应该回答选项A的内容:"["正确"]"` +} + +func fillBlankExample() string { + return `比如: +题目类型:填空题 +题目内容:新中国成立于( )年。 + +那么你应该回答:"["1949"]"` +} + +func shortAnswerExample() string { + return `比如: +题目类型:简答题 +题目内容:请简述新中国成立的历史意义 + +那么你应该回答:"["新中国的成立标志着中国人民从此站起来了,结束了半殖民地半封建社会的历史..."]"` +} diff --git a/internal/application/quesbank/quesbank.go b/internal/application/quesbank/quesbank.go new file mode 100644 index 0000000..bd13614 --- /dev/null +++ b/internal/application/quesbank/quesbank.go @@ -0,0 +1 @@ +package quesbank diff --git a/internal/application/quesbank/question.go b/internal/application/quesbank/question.go new file mode 100644 index 0000000..3fdbbb2 --- /dev/null +++ b/internal/application/quesbank/question.go @@ -0,0 +1,23 @@ +package quesbank + +// QuestionType 题目类型 +type QuestionType int + +const ( + QuestionTypeSingleChoice QuestionType = 0 // 单选题 + QuestionTypeMultipleChoice QuestionType = 1 // 多选题 + QuestionTypeFillBlank QuestionType = 2 // 填空题 + QuestionTypeTrueFalse QuestionType = 3 // 判断题 + QuestionTypeShortAnswer QuestionType = 4 // 简答题 +) + +// Question 通用题目结构 +type Question struct { + Qid string `json:"qid"` // 题目ID + Type QuestionType `json:"type"` // 题目类型 + Text string `json:"text"` // 题目内容 + Options map[string]string `json:"options"` // 选项 (A: xxx, B: xxx) + Answers []string `json:"answers"` // 答案 + BlankCount int `json:"blankCount"` // 填空题空格数量 + BlankAnswer map[string]string `json:"blankAnswer"` // 填空题答案 +} diff --git a/internal/application/xuexitong/ai_answer.go b/internal/application/xuexitong/ai_answer.go new file mode 100644 index 0000000..426ce77 --- /dev/null +++ b/internal/application/xuexitong/ai_answer.go @@ -0,0 +1,145 @@ +package xuexitong + +import ( + "context" + "errors" + "fmt" + "likedream-goframe/internal/application/quesbank" + "likedream-goframe/internal/application/xuexitong/entity" + "likedream-goframe/internal/framework/logger" + "strings" + + "go.uber.org/zap" +) + +// AIType 类型别名,引用 quesbank 包 +type AIType = quesbank.AIType + +// AI类型常量 +const ( + AITypeChatGLM = quesbank.AITypeChatGLM + AITypeXingHuo = quesbank.AITypeXingHuo + AITypeTongYi = quesbank.AITypeTongYi + AITypeDouBao = quesbank.AITypeDouBao + AITypeOpenAI = quesbank.AITypeOpenAI + AITypeOther = quesbank.AITypeOther +) + +// AIConfig 类型别名 +type AIConfig = quesbank.AIConfig + +// convertToQuesbankQuestion 将学习通题目转换为通用题目格式 +func convertToQuesbankQuestion(q entity.WorkQuestion) quesbank.Question { + return quesbank.Question{ + Qid: q.Qid, + Type: quesbank.QuestionType(q.Type), + Text: q.Text, + Options: q.Options, + Answers: q.Answers, + BlankCount: q.BlankCount, + BlankAnswer: q.BlankAnswer, + } +} + +// AIAnswerWork AI答题处理作业 +func (c *XuexitongCourse) AIAnswerWork(ctx context.Context, workDto *entity.WorkDto, aiConfig AIConfig) error { + aiService := quesbank.NewAIAnswerService(aiConfig) + + for i := range workDto.Questions { + q := &workDto.Questions[i] + + // 转换为通用题目格式 + question := convertToQuesbankQuestion(*q) + + // 构建提示词 + prompt := quesbank.BuildQuestionPrompt(question) + + // 获取AI答案 + aiResponse, err := aiService.GetAnswer(ctx, prompt) + if err != nil { + logger.Logger.Error("AI答题失败", + zap.String("qid", q.Qid), + zap.String("text", q.Text), + zap.Error(err)) + continue + } + + // 解析答案 + answers, err := quesbank.ParseAIAnswer(aiResponse) + if err != nil { + logger.Logger.Error("解析AI答案失败", + zap.String("qid", q.Qid), + zap.String("aiResponse", aiResponse), + zap.Error(err)) + continue + } + + // 根据题型处理答案 + switch q.Type { + case entity.QuestionTypeSingleChoice, entity.QuestionTypeMultipleChoice: + var matchedAnswers []string + for _, ans := range answers { + matched := quesbank.MatchAnswerToOption(ans, q.Options) + if matched != "" { + matchedAnswers = append(matchedAnswers, matched) + } + } + q.Answers = matchedAnswers + + case entity.QuestionTypeTrueFalse: + if len(answers) > 0 { + q.Answers = answers + } + + case entity.QuestionTypeFillBlank: + q.BlankAnswer = make(map[string]string) + for idx, ans := range answers { + key := fmt.Sprintf("第%d空", idx+1) + q.BlankAnswer[key] = ans + } + q.BlankCount = len(answers) + + case entity.QuestionTypeShortAnswer: + if len(answers) > 0 { + q.Answers = []string{strings.Join(answers, "")} + } + } + + logger.Logger.Debug("AI答题成功", + zap.String("qid", q.Qid), + zap.Int("type", int(q.Type)), + zap.Strings("answers", q.Answers)) + } + + return nil +} + +// AutoCompleteWorkWithAI 使用AI自动完成作业 +func (c *XuexitongCourse) AutoCompleteWorkWithAI(ctx context.Context, p *entity.PointWorkDto, aiConfig AIConfig, autoSubmit bool) (string, error) { + // 获取作业题目 + workDto, err := c.FetchWorkQuestions(ctx, p) + if err != nil { + return "", fmt.Errorf("获取作业题目失败: %w", err) + } + + if len(workDto.Questions) == 0 { + return "", errors.New("作业无题目") + } + + logger.Logger.Info("开始AI答题", + zap.String("title", workDto.Title), + zap.Int("questionCount", len(workDto.Questions))) + + // AI答题 + if err := c.AIAnswerWork(ctx, workDto, aiConfig); err != nil { + return "", fmt.Errorf("AI答题失败: %w", err) + } + + // 提交答案 + result, err := c.SubmitWorkAnswers(ctx, workDto, !autoSubmit) + if err != nil { + return "", fmt.Errorf("提交答案失败: %w", err) + } + + return result, nil +} diff --git a/internal/application/xuexitong/xuexitong_auto_study.go b/internal/application/xuexitong/xuexitong_auto_study.go new file mode 100644 index 0000000..ea54122 --- /dev/null +++ b/internal/application/xuexitong/xuexitong_auto_study.go @@ -0,0 +1,317 @@ +package xuexitong + +import ( + "context" + "fmt" + "likedream-goframe/internal/application/xuexitong/entity" + "likedream-goframe/internal/framework/logger" + + "go.uber.org/zap" +) + +// AutoStudyByCourseID 通过课程ID自动刷课(定时任务主入口) +func (c *XuexitongCourse) AutoStudyByCourseID(ctx context.Context, courseID string) error { + return c.AutoStudyByCourseIDWithOptions(ctx, courseID, nil) +} + +// AutoStudyByCourseIDWithOptions 通过课程ID自动刷课(带选项) +func (c *XuexitongCourse) AutoStudyByCourseIDWithOptions(ctx context.Context, courseID string, opts *AutoStudyOptions) error { + logger.Logger.Info(fmt.Sprintf("开始刷课,课程ID: %s", courseID)) + + courses := c.PullCourseList(ctx) + if len(courses) == 0 { + return fmt.Errorf("没有找到任何课程") + } + + var targetCourse *entity.XueXiTCourse + for _, course := range courses { + if course.CourseID == courseID { + targetCourse = &course + break + } + } + + if targetCourse == nil { + return fmt.Errorf("未找到课程ID为 %s 的课程", courseID) + } + + if !targetCourse.IsStart { + return fmt.Errorf("课程 %s 未开课", targetCourse.CourseName) + } + + if targetCourse.State != 0 { + return fmt.Errorf("课程 %s 已结束", targetCourse.CourseName) + } + + if opts != nil { + return c.AutoStudyCourseWithOptions(ctx, *targetCourse, opts) + } + return c.AutoStudyCourse(ctx, *targetCourse) +} + +// AutoStudyAllCourses 自动学习所有课程 +func (c *XuexitongCourse) AutoStudyAllCourses(ctx context.Context) error { + logger.Logger.Info("开始自动刷所有课程任务") + + courses := c.PullCourseList(ctx) + if len(courses) == 0 { + logger.Logger.Warn("没有找到任何课程") + return nil + } + + logger.Logger.Info(fmt.Sprintf("找到 %d 个课程", len(courses))) + + for i, course := range courses { + logger.Logger.Info(fmt.Sprintf("开始刷课 [%d/%d]: %s", i+1, len(courses), course.CourseName)) + + if !course.IsStart { + logger.Logger.Info(fmt.Sprintf("课程 %s 未开课,跳过", course.CourseName)) + continue + } + + if course.State != 0 { + logger.Logger.Info(fmt.Sprintf("课程 %s 已结束,跳过", course.CourseName)) + continue + } + + err := c.AutoStudyCourse(ctx, course) + if err != nil { + logger.Logger.Error(fmt.Sprintf("刷课失败: %s", course.CourseName), zap.Error(err)) + continue + } + + logger.Logger.Info(fmt.Sprintf("完成刷课 [%d/%d]: %s", i+1, len(courses), course.CourseName)) + } + + logger.Logger.Info("所有课程刷课任务完成") + return nil +} + +// AutoStudyCourse 自动学习指定课程的所有章节 +func (c *XuexitongCourse) AutoStudyCourse(ctx context.Context, course entity.XueXiTCourse) error { + logger.Logger.Info(fmt.Sprintf("开始刷课程: %s", course.CourseName)) + + chapters := c.PullCourseChapterList(ctx, course) + if len(chapters) == 0 { + logger.Logger.Warn(fmt.Sprintf("课程 %s 没有章节", course.CourseName)) + return nil + } + + _, err := c.FetchChapterPointStatus(ctx, course, chapters) + if err != nil { + logger.Logger.Warn("获取章节状态失败", zap.Error(err)) + } + + for _, chapter := range chapters { + if err := c.checkTimeRestriction(); err != nil { + logger.Logger.Info("时间限制,暂停刷课", zap.String("course", course.CourseName)) + return err + } + + if chapter.JobCount > 0 || chapter.PointTotal > chapter.PointFinish { + err := c.AutoStudyChapter(ctx, course, chapter) + if err != nil { + if err == ErrTimeRestriction { + return err + } + logger.Logger.Error(fmt.Sprintf("刷章节失败: %s", chapter.Name), zap.Error(err)) + continue + } + } + } + + logger.Logger.Info(fmt.Sprintf("课程完成: %s", course.CourseName)) + return nil +} + +// AutoStudyChapter 自动学习指定章节的所有任务点 +func (c *XuexitongCourse) AutoStudyChapter(ctx context.Context, course entity.XueXiTCourse, chapter entity.XueXiTCourseChapter) error { + if err := c.EnterChapterForwardCall(ctx, course, chapter.ID); err != nil { + logger.Logger.Warn("EnterChapterForwardCall失败", zap.Error(err)) + } + + cards, points, err := c.FetchChapterCards(ctx, course, chapter.ID) + if err != nil { + return fmt.Errorf("获取章节任务点失败: %w", err) + } + + if len(cards) == 0 || len(points) == 0 { + logger.Logger.Info(fmt.Sprintf("章节 %s 没有任务点", chapter.Name)) + return nil + } + + for i := range points { + if points[i].Type == entity.ChapterPointTypeVideo { + logger.Logger.Debug("视频任务点参数", + zap.String("objectID", points[i].ObjectID), + zap.String("jobID", points[i].JobID), + zap.String("otherInfo", points[i].OtherInfo), + zap.String("attDurationEnc", points[i].AttDurationEnc), + zap.String("videoFaceCaptureEnc", points[i].VideoFaceCaptureEnc), + zap.Int("FID", points[i].FID)) + } + } + + for i := range points { + if err := c.checkTimeRestriction(); err != nil { + logger.Logger.Info("时间限制,暂停刷课", zap.String("chapter", chapter.Name)) + return err + } + + point := &points[i] + if point.Type == 0 { + continue + } + + switch point.Type { + case entity.ChapterPointTypeVideo: + if err := c.AutoPlayVideoByPoint(ctx, course, point); err != nil { + logger.Logger.Error("视频刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + case entity.ChapterPointTypeDocument: + if err := c.AutoReadDocumentByPoint(ctx, course, point); err != nil { + logger.Logger.Error("文档刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + case entity.ChapterPointTypeWork: + // 作业需要AI配置,这里暂时跳过,使用 AutoStudyChapterWithAI 方法处理 + logger.Logger.Info("作业任务点需要AI配置,跳过", zap.String("title", point.Title)) + case entity.ChapterPointTypeHyperlink: + if err := c.AutoCompleteHyperlinkByPoint(ctx, course, point); err != nil { + logger.Logger.Error("外链刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + case entity.ChapterPointTypeLive: + if err := c.AutoWatchLiveByPoint(ctx, course, point); err != nil { + logger.Logger.Error("直播刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + } + } + + return nil +} + +// AutoStudyOptions 自动学习选项 +type AutoStudyOptions struct { + AIConfig *AIConfig // AI配置(用于作业答题) + AutoSubmit bool // 是否自动提交作业 +} + +// AutoStudyCourseWithOptions 带选项的自动学习课程 +func (c *XuexitongCourse) AutoStudyCourseWithOptions(ctx context.Context, course entity.XueXiTCourse, opts *AutoStudyOptions) error { + logger.Logger.Info(fmt.Sprintf("开始刷课程: %s", course.CourseName)) + + chapters := c.PullCourseChapterList(ctx, course) + if len(chapters) == 0 { + logger.Logger.Warn(fmt.Sprintf("课程 %s 没有章节", course.CourseName)) + return nil + } + + _, err := c.FetchChapterPointStatus(ctx, course, chapters) + if err != nil { + logger.Logger.Warn("获取章节状态失败", zap.Error(err)) + } + + for _, chapter := range chapters { + if err := c.checkTimeRestriction(); err != nil { + logger.Logger.Info("时间限制,暂停刷课", zap.String("course", course.CourseName)) + return err + } + + if chapter.JobCount > 0 && chapter.PointTotal > chapter.PointFinish { + logger.Logger.Info("开始处理章节", zap.String("chapter", chapter.Name)) + err := c.AutoStudyChapterWithOptions(ctx, course, chapter, opts) + if err != nil { + if err == ErrTimeRestriction { + return err + } + logger.Logger.Error(fmt.Sprintf("刷章节失败: %s", chapter.Name), zap.Error(err)) + continue + } + } + } + + logger.Logger.Info(fmt.Sprintf("课程完成: %s", course.CourseName)) + return nil +} + +// AutoStudyChapterWithOptions 带选项的自动学习章节 +func (c *XuexitongCourse) AutoStudyChapterWithOptions(ctx context.Context, course entity.XueXiTCourse, chapter entity.XueXiTCourseChapter, opts *AutoStudyOptions) error { + if err := c.EnterChapterForwardCall(ctx, course, chapter.ID); err != nil { + logger.Logger.Warn("EnterChapterForwardCall失败", zap.Error(err)) + } + + cards, points, err := c.FetchChapterCards(ctx, course, chapter.ID) + if err != nil { + return fmt.Errorf("获取章节任务点失败: %w", err) + } + + if len(cards) == 0 || len(points) == 0 { + logger.Logger.Info(fmt.Sprintf("章节 %s 没有任务点", chapter.Name)) + return nil + } + + logger.Logger.Info("开始处理章节任务点", + zap.String("chapter", chapter.Name), + zap.Int("pointCount", len(points))) + + for i := range points { + if err := c.checkTimeRestriction(); err != nil { + logger.Logger.Info("时间限制,暂停刷课", zap.String("chapter", chapter.Name)) + return err + } + + point := &points[i] + if point.IsPassed { + logger.Logger.Debug("任务点已通过,跳过", zap.String("title", point.Title)) + continue + } + if point.Type == 0 { + logger.Logger.Debug("跳过类型为0的任务点", zap.Int("index", i)) + continue + } + + logger.Logger.Info("处理任务点", + zap.Int("index", i), + zap.Any("type", point.Type), + zap.String("title", point.Title), + zap.String("jobID", point.JobID), + zap.Bool("isPassed", point.IsPassed)) + + switch point.Type { + case entity.ChapterPointTypeVideo: + if err := c.AutoPlayVideoByPoint(ctx, course, point); err != nil { + logger.Logger.Error("视频刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + case entity.ChapterPointTypeDocument: + if err := c.AutoReadDocumentByPoint(ctx, course, point); err != nil { + logger.Logger.Error("文档刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + case entity.ChapterPointTypeWork: + logger.Logger.Info("发现作业任务点", + zap.String("title", point.Title), + zap.String("workID", point.WorkID), + zap.String("jobID", point.JobID), + zap.Int("cardIndex", point.CardIndex), + zap.Int("knowledgeID", point.KnowledgeID), + zap.String("cpi", point.Cpi), + zap.Bool("hasAIConfig", opts != nil && opts.AIConfig != nil)) + + if opts != nil && opts.AIConfig != nil { + if err := c.AutoCompleteWorkByPoint(ctx, course, point, opts.AIConfig, opts.AutoSubmit); err != nil { + logger.Logger.Error("作业刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + } else { + logger.Logger.Info("作业任务点需要AI配置,跳过", zap.String("title", point.Title)) + } + case entity.ChapterPointTypeHyperlink: + if err := c.AutoCompleteHyperlinkByPoint(ctx, course, point); err != nil { + logger.Logger.Error("外链刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + case entity.ChapterPointTypeLive: + if err := c.AutoWatchLiveByPoint(ctx, course, point); err != nil { + logger.Logger.Error("直播刷课失败", zap.Error(err), zap.String("title", point.Title)) + } + } + } + + return nil +} diff --git a/internal/application/xuexitong/xuexitong_chapter.go b/internal/application/xuexitong/xuexitong_chapter.go new file mode 100644 index 0000000..c18b4cc --- /dev/null +++ b/internal/application/xuexitong/xuexitong_chapter.go @@ -0,0 +1,758 @@ +package xuexitong + +import ( + "context" + "encoding/json" + "fmt" + "likedream-goframe/internal/application/captcha" + "likedream-goframe/internal/application/xuexitong/entity" + errors2 "likedream-goframe/internal/common/errors" + "likedream-goframe/internal/framework/logger" + "likedream-goframe/internal/utils" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/avast/retry-go/v4" + "github.com/samber/oops" + "go.uber.org/zap" + "golang.org/x/net/html" +) + +// PullCourseChapterList 拉取学习通课程章节列表并返回 +func (c *XuexitongCourse) PullCourseChapterList(ctx context.Context, course entity.XueXiTCourse) []entity.XueXiTCourseChapter { + var chapterString string + key, _ := strconv.Atoi(course.Key) + + e := retry.Do(func() error { + params := url.Values{} + params.Add("id", strconv.Itoa(key)) + params.Add("personid", strconv.Itoa(course.Cpi)) + params.Add("fields", "id,bbsid,classscore,isstart,allowdownload,chatid,name,state,isfiled,visiblescore,hideclazz,begindate,forbidintoclazz,coursesetting.fields(id,courseid,hiddencoursecover,coursefacecheck),course.fields(id,belongschoolid,name,infocontent,objectid,app,bulletformat,mappingcourseid,imageurl,teacherfactor,jobcount,knowledge.fields(id,name,indexOrder,parentnodeid,status,isReview,layer,label,jobcount,begintime,endtime,attachment.fields(id,type,objectid,extension).type(video)))") + params.Add("view", "json") + + get, err := c.client.R().SetHeaders(map[string]string{ + "User-Agent": GetUA("mobile"), + "Accept-Language": "zh_CN", + "Host": "mooc1-api.chaoxing.com", + "Connection": "Keep-Alive", + }).Get(ApiPullChapter + "?" + params.Encode()) + if err != nil { + return err + } + chapterString = get.String() + return nil + }, retry.Attempts(3), retry.Delay(5*time.Second)) + utils.OkOrPanic(e, oops.WithContext(ctx).With("chapterString", chapterString).Wrap) + + var chapterMap map[string]interface{} + err := json.Unmarshal([]byte(chapterString), &chapterMap) + if err != nil { + utils.OkOrPanic(errors2.NewBusinessError(503, "解析章节数据失败").WithInnerError(err), oops.WithContext(ctx).Wrap) + } + + dataArr, ok := chapterMap["data"].([]interface{}) + if !ok || len(dataArr) == 0 { + return nil + } + + firstData, ok := dataArr[0].(map[string]interface{}) + if !ok { + return nil + } + + courseData, ok := firstData["course"].(map[string]interface{}) + if !ok { + return nil + } + + courseDataArr, ok := courseData["data"].([]interface{}) + if !ok || len(courseDataArr) == 0 { + return nil + } + + firstCourse, ok := courseDataArr[0].(map[string]interface{}) + if !ok { + return nil + } + + knowledgeData, ok := firstCourse["knowledge"].(map[string]interface{}) + if !ok { + return nil + } + + knowledgeArr, ok := knowledgeData["data"].([]interface{}) + if !ok { + return nil + } + + var chapters []entity.XueXiTCourseChapter + for _, kd := range knowledgeArr { + kdMap, ok := kd.(map[string]interface{}) + if !ok { + continue + } + chapter := entity.XueXiTCourseChapter{ + ID: int(kdMap["id"].(float64)), + Name: kdMap["name"].(string), + ParentNodeID: int(kdMap["parentnodeid"].(float64)), + Layer: int(kdMap["layer"].(float64)), + Label: kdMap["label"].(string), + IndexOrder: int(kdMap["indexorder"].(float64)), + JobCount: int(kdMap["jobcount"].(float64)), + Status: kdMap["status"].(string), + IsReview: int(kdMap["isreview"].(float64)), + } + chapters = append(chapters, chapter) + } + + sort.Slice(chapters, func(i, j int) bool { + iLabelParts := strings.Split(chapters[i].Label, ".") + jLabelParts := strings.Split(chapters[j].Label, ".") + for k := range iLabelParts { + if k >= len(jLabelParts) { + return false + } + iv, _ := strconv.Atoi(iLabelParts[k]) + jv, _ := strconv.Atoi(jLabelParts[k]) + if iv != jv { + return iv < jv + } + } + return len(iLabelParts) < len(jLabelParts) + }) + + return chapters +} + +// FetchChapterPointStatus 获取章节任务点状态 +func (c *XuexitongCourse) FetchChapterPointStatus(ctx context.Context, course entity.XueXiTCourse, chapters []entity.XueXiTCourseChapter) (entity.ChapterPointStatus, error) { + var nodes []string + for _, ch := range chapters { + nodes = append(nodes, strconv.Itoa(ch.ID)) + } + + ts := time.Now().UnixMilli() + classID, _ := strconv.Atoi(course.Key) + courseID, _ := strconv.Atoi(course.CourseID) + + values := url.Values{ + "view": {"json"}, + "nodes": {strings.Join(nodes, ",")}, + "clazzid": {strconv.Itoa(classID)}, + "time": {strconv.FormatInt(ts, 10)}, + "userid": {c.userID}, + "cpi": {strconv.Itoa(course.Cpi)}, + "courseid": {strconv.Itoa(courseID)}, + } + + logger.Logger.Debug("FetchChapterPointStatus 请求参数", + zap.String("userID", c.userID), + zap.Int("classID", classID), + zap.Int("courseID", courseID), + zap.Int("cpi", course.Cpi), + zap.Int("nodesCount", len(nodes))) + + 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(values.Encode()).Post(ApiChapterPoint) + if err != nil { + return err + } + body = resp.String() + return nil + }, retry.Attempts(3), retry.Delay(2*time.Second)) + + if err != nil { + return nil, err + } + + logger.Logger.Debug("FetchChapterPointStatus 响应", zap.String("body", body)) + + var status entity.ChapterPointStatus + err = json.Unmarshal([]byte(body), &status) + if err != nil { + logger.Logger.Error("FetchChapterPointStatus 解析失败", zap.Error(err), zap.String("body", body)) + return nil, err + } + + for i := range chapters { + chapterIDStr := strconv.Itoa(chapters[i].ID) + if pointData, exists := status[chapterIDStr]; exists { + chapters[i].PointTotal = pointData.UnFinishCount + pointData.FinishCount + chapters[i].PointFinish = pointData.FinishCount + } + } + + return status, nil +} + +// EnterChapterForwardCall 进入章节前的前置调用 +func (c *XuexitongCourse) EnterChapterForwardCall(ctx context.Context, course entity.XueXiTCourse, chapterID int) error { + urlStr := fmt.Sprintf("%s?courseId=%s&clazzid=%s&chapterId=%d&cpi=%d&verificationcode=&mooc2=1&toComputer=falseµTopicId=0", + ApiEnterChapter, course.CourseID, course.Key, chapterID, course.Cpi) + _, err := c.client.R().SetHeaders(map[string]string{ + "User-Agent": GetUA("mobile"), + }).Get(urlStr) + + return err +} + +// FetchChapterCards 获取章节任务点卡片(JSON方式)- 支持所有任务点类型 +func (c *XuexitongCourse) FetchChapterCards(ctx context.Context, course entity.XueXiTCourse, chapterID int) ([]entity.Card, []entity.ChapterPoint, error) { + var body string + courseID, _ := strconv.Atoi(course.CourseID) + + err := retry.Do(func() error { + resp, err := c.client.R().SetHeaders(map[string]string{ + "User-Agent": GetUA("mobile"), + "Accept-Language": "zh_CN", + }).SetQueryParams(map[string]string{ + "id": strconv.Itoa(chapterID), + "courseid": strconv.Itoa(courseID), + "fields": "id,parentnodeid,indexorder,label,layer,name,begintime,createtime,lastmodifytime,status,jobUnfinishedCount,clickcount,openlock,card.fields(id,knowledgeid,title,knowledgeTitile,description,cardorder).contentcard(all)", + "view": "json", + "token": "4faa8662c59590c6f43ae9fe5b002b42", + "_time": strconv.FormatInt(time.Now().UnixMilli(), 10), + }).Get(ApiChapterCards) + if err != nil { + return err + } + + body = resp.String() + if strings.Contains(body, "请输入验证码") { + logger.Logger.Warn("触发验证码,正在进行AI智能识别绕过.....") + e := retry.Do(func() error { + bytes, e := ApiVerifyCodeBytes(c.client) + if e != nil { + return e + } + codeResult := captcha.ApiSemiOCRVerification(bytes, 1, 23) + time.Sleep(5 * time.Second) + if ApiPassVerifyCode(c.client, codeResult, 5) { + return nil + } + return errors2.NewBusinessError(503, "验证码识别失败") + }, retry.Attempts(5), retry.Delay(5*time.Second)) + return e + } + + statusCode := resp.StatusCode() + if statusCode == 500 || statusCode == 202 || statusCode == 400 || statusCode == 403 { + return errors2.NewBusinessError(statusCode, fmt.Sprintf("获取任务点失败,状态码: %d", statusCode)) + } + return nil + }, retry.Attempts(3), retry.Delay(5*time.Second)) + + if err != nil { + return nil, nil, err + } + + var apiResp entity.CardAPIResponse + if err := json.Unmarshal([]byte(body), &apiResp); err != nil { + return nil, nil, err + } + + if len(apiResp.Data) == 0 { + return nil, nil, nil + } + + dataItem := apiResp.Data[0] + cards := dataItem.Card.Data + + var points []entity.ChapterPoint + cpiStr := strconv.Itoa(course.Cpi) + + for cardIndex, card := range cards { + logger.Logger.Debug("处理卡片", + zap.Int("cardIndex", cardIndex), + zap.Int("cardID", card.ID), + zap.Int("cardKnowledgeID", card.KnowledgeID), + zap.String("cardTitle", card.Title), + zap.Int("descriptionLength", len(card.Description))) + + if card.Description == "" { + continue + } + iframes, err := c.parseIframeData(card.Description) + if err != nil { + logger.Logger.Debug("解析卡片失败", zap.Error(err)) + continue + } + + for _, iframe := range iframes { + if !iframe.HasData { + continue + } + pointType, ok := iframe.Other["module"] + logger.Logger.Debug("解析到任务点", zap.String("module", pointType), zap.Bool("hasModule", ok), zap.Any("data", iframe.Data)) + if !ok { + continue + } + + point := entity.ChapterPoint{ + CardIndex: cardIndex, + KnowledgeID: card.KnowledgeID, + CourseID: course.CourseID, + ClassID: course.Key, + Cpi: cpiStr, + } + + switch pointType { + case "insertvideo", "insertaudio": + objectID, ok := iframe.Data["objectid"].(string) + if !ok || objectID == "" { + continue + } + jobID, _ := iframe.Data["_jobid"].(string) + point.Type = entity.ChapterPointTypeVideo + point.ObjectID = objectID + point.JobID = jobID + + cords2, err := c.FetchChapterCards2(ctx, course, card.KnowledgeID) + if err == nil && cords2 != nil { + c.fillVideoPointFromCards2(cords2, &point, objectID) + } + + case "insertdoc", "document": + objectID, _ := iframe.Data["objectid"].(string) + jobID, _ := iframe.Data["_jobid"].(string) + if jobID == "" { + continue + } + point.Type = entity.ChapterPointTypeDocument + point.ObjectID = objectID + point.JobID = jobID + point.DocumentType = "document" + + case "insertbook": + jobID, _ := iframe.Data["_jobid"].(string) + if jobID == "" { + continue + } + point.Type = entity.ChapterPointTypeDocument + point.JobID = jobID + point.DocumentType = "insertbook" + + case "work", "insertwork": + workID, _ := iframe.Data["workid"].(string) + schoolID, _ := iframe.Data["schoolid"].(string) + jobID, _ := iframe.Data["_jobid"].(string) + if workID == "" || jobID == "" { + continue + } + if schoolID == "" { + schoolID = "0" + } + point.Type = entity.ChapterPointTypeWork + point.WorkID = workID + point.SchoolID = schoolID + point.JobID = jobID + + case "hyperlink", "insertlink": + jobID, _ := iframe.Data["_jobid"].(string) + if jobID == "" { + continue + } + linkType := 0 + if lt, ok := iframe.Data["linkType"].(float64); ok { + linkType = int(lt) + } + point.Type = entity.ChapterPointTypeHyperlink + point.JobID = jobID + point.LinkType = linkType + + case "insertlive": + jobID, _ := iframe.Data["_jobid"].(string) + if jobID == "" { + continue + } + title, _ := iframe.Data["title"].(string) + liveStatus, _ := iframe.Data["liveStatus"].(string) + streamName, _ := iframe.Data["streamName"].(string) + vdoid, _ := iframe.Data["vdoid"].(string) + liveId := "" + if lid, ok := iframe.Data["liveId"].(float64); ok { + liveId = strconv.FormatInt(int64(lid), 10) + } + live, _ := iframe.Data["live"].(bool) + + point.Type = entity.ChapterPointTypeLive + point.JobID = jobID + point.Title = title + point.LiveStatusStr = liveStatus + point.StreamName = streamName + point.Vdoid = vdoid + point.LiveId = liveId + point.Live = live + point.Module = pointType + point.UserId = c.userID + + default: + logger.Logger.Debug("未知的任务点类型", zap.String("type", pointType)) + continue + } + + points = append(points, point) + } + } + + return cards, points, nil +} + +// fillVideoPointFromCards2 从 FetchChapterCards2 结果填充视频任务点参数 +func (c *XuexitongCourse) fillVideoPointFromCards2(cords2 map[string]interface{}, point *entity.ChapterPoint, objectID string) { + if attachments, ok := cords2["attachments"].([]interface{}); ok { + for _, att := range attachments { + if attMap, ok := att.(map[string]interface{}); ok { + attObjectID, _ := attMap["objectId"].(string) + if attObjectID == "" { + if prop, ok := attMap["property"].(map[string]interface{}); ok { + attObjectID, _ = prop["objectid"].(string) + } + } + if attObjectID == objectID { + if otherInfo, ok := attMap["otherInfo"].(string); ok && len(otherInfo) > 80 { + parts := strings.SplitN(otherInfo, "&", 2) + if len(parts) > 0 { + point.OtherInfo = parts[0] + } + } + if jid, ok := attMap["jobid"].(string); ok && jid != "" { + point.JobID = jid + } + if playTime, ok := attMap["playTime"].(float64); ok { + point.PlayTime = int(playTime) / 1000 + } + if ade, ok := attMap["attDurationEnc"].(string); ok { + point.AttDurationEnc = ade + } + if vfce, ok := attMap["videoFaceCaptureEnc"].(string); ok { + point.VideoFaceCaptureEnc = vfce + } + if isPassed, ok := attMap["isPassed"].(bool); ok { + point.IsPassed = isPassed + } + point.RT = 0.9 + if prop, ok := attMap["property"].(map[string]interface{}); ok { + if rtObj, ok := prop["rt"]; ok { + switch v := rtObj.(type) { + case float64: + point.RT = v + case string: + if rt, err := strconv.ParseFloat(v, 64); err == nil { + point.RT = rt + } + } + } + } + logger.Logger.Debug("从 FetchChapterCards2 获取到视频参数", + zap.String("objectID", objectID), + zap.String("otherInfo", point.OtherInfo), + zap.String("attDurationEnc", point.AttDurationEnc), + zap.String("videoFaceCaptureEnc", point.VideoFaceCaptureEnc), + zap.Float64("RT", point.RT)) + break + } + } + } + } + if defaults, ok := cords2["defaults"].(map[string]interface{}); ok { + if fid, ok := defaults["fid"].(string); ok { + point.FID, _ = strconv.Atoi(fid) + } + if cpiVal, ok := defaults["cpi"]; ok { + switch v := cpiVal.(type) { + case float64: + point.Cpi = strconv.FormatInt(int64(v), 10) + case string: + point.Cpi = v + } + } + if userid, ok := defaults["userid"].(string); ok { + c.userID = userid + } + } +} + +// parseIframeData 从HTML中解析iframe数据 +func (c *XuexitongCourse) parseIframeData(htmlString string) ([]entity.IframeAttributes, error) { + node, err := html.Parse(strings.NewReader(htmlString)) + if err != nil { + return nil, err + } + + var iframes []entity.IframeAttributes + var traverse func(n *html.Node) + traverse = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "iframe" { + attrs := entity.IframeAttributes{ + Other: make(map[string]string), + } + hasData := false + for _, attr := range n.Attr { + if attr.Key == "data" && strings.TrimSpace(attr.Val) != "" { + hasData = true + cleanedData := strings.ReplaceAll(attr.Val, """, "\"") + cleanedData = regexp.MustCompile(`\s+`).ReplaceAllString(cleanedData, "") + if err := json.Unmarshal([]byte(cleanedData), &attrs.Data); err != nil { + logger.Logger.Debug("Failed to decode JSON", zap.Error(err)) + } + } else { + attrs.Other[attr.Key] = attr.Val + } + } + attrs.HasData = hasData + iframes = append(iframes, attrs) + } + for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { + traverse(ch) + } + } + traverse(node) + return iframes, nil +} + +// FetchChapterCards2 获取章节卡片(新接口,用于获取 otherInfo 等) +func (c *XuexitongCourse) FetchChapterCards2(ctx context.Context, course entity.XueXiTCourse, knowledgeID int) (map[string]interface{}, error) { + urlStr := fmt.Sprintf("%s?clazzid=%s&courseid=%s&knowledgeid=%d&num=0&ut=s&cpi=%d&v=2025-0424-1038-3&mooc2=1&isMicroCourse=false&editorPreview=0", + ApiChapterCards2, course.Key, course.CourseID, knowledgeID, course.Cpi) + + logger.Logger.Debug("FetchChapterCards2 请求", + zap.String("url", urlStr), + zap.Int("knowledgeID", knowledgeID)) + + var body string + err := retry.Do(func() error { + resp, err := c.client.R().SetHeaders(map[string]string{ + "User-Agent": GetUA("mobile"), + }).Get(urlStr) + if err != nil { + return err + } + body = resp.String() + return nil + }, retry.Attempts(3), retry.Delay(2*time.Second)) + + if err != nil { + logger.Logger.Error("FetchChapterCards2 请求失败", zap.Error(err)) + return nil, err + } + + logger.Logger.Debug("FetchChapterCards2 响应", + zap.Int("bodyLength", len(body)), + zap.Bool("containsMArg", strings.Contains(body, "mArg"))) + + re := regexp.MustCompile(`mArg = ([^;]{6,})`) + matches := re.FindStringSubmatch(body) + if len(matches) <= 1 { + logger.Logger.Warn("FetchChapterCards2 未找到 mArg") + return nil, nil + } + + logger.Logger.Debug("FetchChapterCards2 提取到 mArg", zap.Int("jsonLength", len(matches[1]))) + + var result map[string]interface{} + if err := json.Unmarshal([]byte(matches[1]), &result); err != nil { + logger.Logger.Error("FetchChapterCards2 解析 JSON 失败", zap.Error(err)) + return nil, err + } + + if attachments, ok := result["attachments"].([]interface{}); ok { + logger.Logger.Debug("FetchChapterCards2 解析成功", + zap.Int("attachmentsCount", len(attachments))) + } + + return result, nil +} + +// PageMobileChapterCard 获取章节卡片页面(SSR方式,用于获取附件信息) +func (c *XuexitongCourse) PageMobileChapterCard(ctx context.Context, course entity.XueXiTCourse, knowledgeID, cardIndex int) (*entity.AttachmentSetting, string, error) { + classID, _ := strconv.Atoi(course.Key) + courseID, _ := strconv.Atoi(course.CourseID) + + params := url.Values{} + params.Add("clazzid", strconv.Itoa(classID)) + params.Add("courseid", strconv.Itoa(courseID)) + params.Add("knowledgeid", strconv.Itoa(knowledgeID)) + params.Add("num", strconv.Itoa(cardIndex)) + params.Add("isPhone", "1") + params.Add("control", "true") + params.Add("cpi", strconv.Itoa(course.Cpi)) + + requestURL := PageChapterCard + "?" + params.Encode() + logger.Logger.Debug("PageMobileChapterCard 请求", + zap.String("url", requestURL), + zap.Int("knowledgeID", knowledgeID), + zap.Int("cardIndex", cardIndex)) + + var body string + err := retry.Do(func() error { + resp, err := c.client.R().SetHeaders(map[string]string{ + "User-Agent": GetUA("mobile"), + }).Get(requestURL) + if err != nil { + return err + } + body = resp.String() + return nil + }, retry.Attempts(3), retry.Delay(2*time.Second)) + + if err != nil { + logger.Logger.Error("PageMobileChapterCard 请求失败", zap.Error(err)) + return nil, "", err + } + + logger.Logger.Debug("PageMobileChapterCard 响应", + zap.Int("bodyLength", len(body)), + zap.Bool("containsAttachmentSetting", strings.Contains(body, "window.AttachmentSetting")), + zap.Bool("containsBlankTips", strings.Contains(body, "blankTips")), + zap.String("bodyPreview", body[:min(len(body), 500)])) + + doc, err := html.Parse(strings.NewReader(body)) + if err != nil { + logger.Logger.Error("解析 HTML 失败", zap.Error(err)) + return nil, "", err + } + + var scriptContent string + var scriptCount int + var traverse func(*html.Node) + traverse = func(n *html.Node) { + if scriptContent != "" { + return + } + if n.Type == html.ElementNode && n.Data == "script" { + hasTypeAttr := false + for _, attr := range n.Attr { + if attr.Key == "type" && strings.TrimSpace(attr.Val) == "text/javascript" { + hasTypeAttr = true + break + } + } + + if hasTypeAttr { + scriptCount++ + if n.FirstChild != nil && n.FirstChild.Type == html.TextNode { + scriptContent = strings.TrimSpace(n.FirstChild.Data) + logger.Logger.Debug("找到 script 标签", + zap.Int("scriptIndex", scriptCount), + zap.Int("contentLength", len(scriptContent)), + zap.Bool("hasAttachmentSetting", strings.Contains(scriptContent, "window.AttachmentSetting"))) + return + } + } + } + + for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { + traverse(ch) + } + } + traverse(doc) + + logger.Logger.Debug("HTML 解析完成", zap.Int("scriptCount", scriptCount), zap.Bool("hasScriptContent", scriptContent != "")) + + if scriptContent == "" || !strings.Contains(scriptContent, "window.AttachmentSetting") { + var blankTips string + traverse = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "p" { + for _, attr := range n.Attr { + if attr.Key == "class" && strings.Contains(attr.Val, "blankTips") { + if n.FirstChild != nil { + blankTips = strings.TrimSpace(n.FirstChild.Data) + } + return + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + traverse(doc) + + if blankTips != "" { + logger.Logger.Warn("章节提示信息", zap.String("blankTips", blankTips)) + } + + if strings.Contains(body, "window.AttachmentSetting") { + scriptContent = body + logger.Logger.Debug("从原始 body 中找到 window.AttachmentSetting") + } else { + logger.Logger.Warn("未找到 window.AttachmentSetting", + zap.Int("knowledgeID", knowledgeID), + zap.Int("cardIndex", cardIndex), + zap.String("blankTips", blankTips)) + return nil, "", nil + } + } + + re := regexp.MustCompile(`window\.AttachmentSetting\s*=\s*(\{.*?\});`) + matches := re.FindStringSubmatch(scriptContent) + var jsonStr string + if len(matches) > 1 { + jsonStr = matches[1] + logger.Logger.Debug("正则提取 JSON 成功", zap.Int("jsonLength", len(jsonStr))) + } else { + jsonStr = extractJSONObject(scriptContent, "window.AttachmentSetting") + if jsonStr == "" { + logger.Logger.Warn("无法提取 JSON", + zap.Int("scriptContentLength", len(scriptContent))) + return nil, "", nil + } + logger.Logger.Debug("括号匹配提取 JSON 成功", zap.Int("jsonLength", len(jsonStr))) + } + + var attachment entity.AttachmentSetting + if err := json.Unmarshal([]byte(jsonStr), &attachment); err != nil { + logger.Logger.Error("解析 AttachmentSetting 失败", + zap.Error(err), + zap.String("jsonStr", jsonStr[:min(len(jsonStr), 500)])) + return nil, "", err + } + + logger.Logger.Debug("解析 AttachmentSetting 成功", + zap.Int("attachmentsCount", len(attachment.Attachments)), + zap.Any("defaults", attachment.Defaults)) + + for idx, att := range attachment.Attachments { + property, _ := att["property"].(map[string]interface{}) + objectid := "" + workid := "" + if property != nil { + if oid, ok := property["objectid"].(string); ok { + objectid = oid + } + if wid, ok := property["workid"].(string); ok { + workid = wid + } + } + otherInfo, _ := att["otherInfo"].(string) + attDurationEnc, _ := att["attDurationEnc"].(string) + videoFaceCaptureEnc, _ := att["videoFaceCaptureEnc"].(string) + enc, _ := att["enc"].(string) + logger.Logger.Debug("附件信息", + zap.Int("index", idx), + zap.String("objectid", objectid), + zap.String("workid", workid), + zap.String("enc", enc), + zap.Bool("hasOtherInfo", otherInfo != ""), + zap.Bool("hasAttDurationEnc", attDurationEnc != ""), + zap.Bool("hasVideoFaceCaptureEnc", videoFaceCaptureEnc != "")) + } + + reEnc := regexp.MustCompile(``) + matchesEnc := reEnc.FindStringSubmatch(body) + enc := "" + if len(matchesEnc) > 1 { + enc = matchesEnc[1] + } + + return &attachment, enc, nil +} diff --git a/internal/application/xuexitong/xuexitong_course.go b/internal/application/xuexitong/xuexitong_course.go index 33953cf..41aac87 100644 --- a/internal/application/xuexitong/xuexitong_course.go +++ b/internal/application/xuexitong/xuexitong_course.go @@ -2,21 +2,13 @@ package xuexitong import ( "context" - "crypto/md5" "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" "likedream-goframe/internal/application/captcha" "likedream-goframe/internal/application/xuexitong/entity" errors2 "likedream-goframe/internal/common/errors" "likedream-goframe/internal/framework/logger" "likedream-goframe/internal/utils" - "net/http" - "net/url" "regexp" - "sort" - "strconv" "strings" "time" @@ -24,7 +16,6 @@ import ( "github.com/bytedance/sonic" "github.com/samber/oops" "go.uber.org/zap" - "golang.org/x/net/html" "resty.dev/v3" ) @@ -144,7 +135,6 @@ func (c *XuexitongCourse) PullCourseList(ctx context.Context) []entity.XueXiTCou } for _, v := range channel.Content.Course.Data { flag := false - // 解析 CourseSquareUrl 获取各种ID sqUrl := v.CourseSquareUrl classId := "" courseID := "" @@ -156,7 +146,6 @@ func (c *XuexitongCourse) PullCourseList(ctx context.Context) []entity.XueXiTCou } if strings.Contains(sqUrl, "userId=") { userIDPart := strings.Split(sqUrl, "userId=")[1] - // 处理 userId 后面可能还有其他参数的情况 if idx := strings.Index(userIDPart, "&"); idx != -1 { c.userID = userIDPart[:idx] } else { @@ -209,528 +198,44 @@ func (c *XuexitongCourse) PullCourseListWithImage(ctx context.Context) []entity. return courseList } -// PullCourseChapterList 拉取学习通课程章节列表并返回 -func (c *XuexitongCourse) PullCourseChapterList(ctx context.Context, course entity.XueXiTCourse) []entity.XueXiTCourseChapter { - var chapterString string - key, _ := strconv.Atoi(course.Key) - - e := retry.Do(func() error { - params := url.Values{} - params.Add("id", strconv.Itoa(key)) - params.Add("personid", strconv.Itoa(course.Cpi)) - params.Add("fields", "id,bbsid,classscore,isstart,allowdownload,chatid,name,state,isfiled,visiblescore,hideclazz,begindate,forbidintoclazz,coursesetting.fields(id,courseid,hiddencoursecover,coursefacecheck),course.fields(id,belongschoolid,name,infocontent,objectid,app,bulletformat,mappingcourseid,imageurl,teacherfactor,jobcount,knowledge.fields(id,name,indexOrder,parentnodeid,status,isReview,layer,label,jobcount,begintime,endtime,attachment.fields(id,type,objectid,extension).type(video)))") - params.Add("view", "json") - - get, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - "Accept-Language": "zh_CN", - "Host": "mooc1-api.chaoxing.com", - "Connection": "Keep-Alive", - }).Get(ApiPullChapter + "?" + params.Encode()) - if err != nil { - return err - } - chapterString = get.String() - return nil - }, retry.Attempts(3), retry.Delay(5*time.Second)) - utils.OkOrPanic(e, oops.WithContext(ctx).With("chapterString", chapterString).Wrap) - - // 解析章节数据 - var chapterMap map[string]interface{} - err := json.Unmarshal([]byte(chapterString), &chapterMap) - if err != nil { - utils.OkOrPanic(errors2.NewBusinessError(503, "解析章节数据失败").WithInnerError(err), oops.WithContext(ctx).Wrap) +// AttachmentsDetectionPoint 从附件设置中检测并填充任务点信息(统一版) +func (c *XuexitongCourse) AttachmentsDetectionPoint(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) error { + if attachment == nil { + return errors2.NewBusinessError(503, "附件设置为空") } - dataArr, ok := chapterMap["data"].([]interface{}) - if !ok || len(dataArr) == 0 { - return nil + switch p.Type { + case entity.ChapterPointTypeVideo: + c.attachmentDetectVideo(attachment, p) + case entity.ChapterPointTypeDocument: + c.attachmentDetectDocument(attachment, p) + case entity.ChapterPointTypeWork: + c.attachmentDetectWork(attachment, p) + case entity.ChapterPointTypeHyperlink: + c.attachmentDetectHyperlink(attachment, p) + case entity.ChapterPointTypeLive: + c.attachmentDetectLive(attachment, p) } - firstData, ok := dataArr[0].(map[string]interface{}) - if !ok { - return nil - } - - courseData, ok := firstData["course"].(map[string]interface{}) - if !ok { - return nil - } - - courseDataArr, ok := courseData["data"].([]interface{}) - if !ok || len(courseDataArr) == 0 { - return nil - } - - firstCourse, ok := courseDataArr[0].(map[string]interface{}) - if !ok { - return nil - } - - knowledgeData, ok := firstCourse["knowledge"].(map[string]interface{}) - if !ok { - return nil - } - - knowledgeArr, ok := knowledgeData["data"].([]interface{}) - if !ok { - return nil - } - - var chapters []entity.XueXiTCourseChapter - for _, kd := range knowledgeArr { - kdMap, ok := kd.(map[string]interface{}) - if !ok { - continue - } - chapter := entity.XueXiTCourseChapter{ - ID: int(kdMap["id"].(float64)), - Name: kdMap["name"].(string), - ParentNodeID: int(kdMap["parentnodeid"].(float64)), - Layer: int(kdMap["layer"].(float64)), - Label: kdMap["label"].(string), - IndexOrder: int(kdMap["indexorder"].(float64)), - JobCount: int(kdMap["jobcount"].(float64)), - Status: kdMap["status"].(string), - IsReview: int(kdMap["isreview"].(float64)), - } - chapters = append(chapters, chapter) - } - - // 按照标签排序 - sort.Slice(chapters, func(i, j int) bool { - iLabelParts := strings.Split(chapters[i].Label, ".") - jLabelParts := strings.Split(chapters[j].Label, ".") - for k := range iLabelParts { - if k >= len(jLabelParts) { - return false - } - iv, _ := strconv.Atoi(iLabelParts[k]) - jv, _ := strconv.Atoi(jLabelParts[k]) - if iv != jv { - return iv < jv - } - } - return len(iLabelParts) < len(jLabelParts) - }) - - return chapters + return nil } -// FetchChapterPointStatus 获取章节任务点状态 -func (c *XuexitongCourse) FetchChapterPointStatus(ctx context.Context, course entity.XueXiTCourse, chapters []entity.XueXiTCourseChapter) (entity.ChapterPointStatus, error) { - var nodes []string - for _, ch := range chapters { - nodes = append(nodes, strconv.Itoa(ch.ID)) - } - - ts := time.Now().UnixMilli() - classID, _ := strconv.Atoi(course.Key) - courseID, _ := strconv.Atoi(course.CourseID) - - values := url.Values{ - "view": {"json"}, - "nodes": {strings.Join(nodes, ",")}, - "clazzid": {strconv.Itoa(classID)}, - "time": {strconv.FormatInt(ts, 10)}, - "userid": {c.userID}, - "cpi": {strconv.Itoa(course.Cpi)}, - "courseid": {strconv.Itoa(courseID)}, - } - - logger.Logger.Debug("FetchChapterPointStatus 请求参数", - zap.String("userID", c.userID), - zap.Int("classID", classID), - zap.Int("courseID", courseID), - zap.Int("cpi", course.Cpi), - zap.Int("nodesCount", len(nodes))) - - 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(values.Encode()).Post(ApiChapterPoint) - if err != nil { - return err - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return nil, err - } - - logger.Logger.Debug("FetchChapterPointStatus 响应", zap.String("body", body)) - - var status entity.ChapterPointStatus - err = json.Unmarshal([]byte(body), &status) - if err != nil { - logger.Logger.Error("FetchChapterPointStatus 解析失败", zap.Error(err), zap.String("body", body)) - return nil, err - } - - // 更新章节的 PointTotal 和 PointFinish 字段 - for i := range chapters { - chapterIDStr := strconv.Itoa(chapters[i].ID) - if pointData, exists := status[chapterIDStr]; exists { - chapters[i].PointTotal = pointData.UnFinishCount + pointData.FinishCount - chapters[i].PointFinish = pointData.FinishCount - } - } - - return status, nil -} - -// EnterChapterForwardCall 进入章节前的前置调用 -func (c *XuexitongCourse) EnterChapterForwardCall(ctx context.Context, course entity.XueXiTCourse, chapterID int) error { - urlStr := fmt.Sprintf("%s?courseId=%s&clazzid=%s&chapterId=%d&cpi=%d&verificationcode=&mooc2=1&toComputer=falseµTopicId=0", - ApiEnterChapter, course.CourseID, course.Key, chapterID, course.Cpi) - _, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - }).Get(urlStr) - - return err -} - -// FetchChapterCards 获取章节任务点卡片(JSON方式)- 支持所有任务点类型 -// 返回 cards 和 points,一个 card 可能对应多个 points(通过 CardIndex 关联) -func (c *XuexitongCourse) FetchChapterCards(ctx context.Context, course entity.XueXiTCourse, chapterID int) ([]entity.Card, []entity.ChapterPoint, error) { - var body string - courseID, _ := strconv.Atoi(course.CourseID) - - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - "Accept-Language": "zh_CN", - }).SetQueryParams(map[string]string{ - "id": strconv.Itoa(chapterID), - "courseid": strconv.Itoa(courseID), - "fields": "id,parentnodeid,indexorder,label,layer,name,begintime,createtime,lastmodifytime,status,jobUnfinishedCount,clickcount,openlock,card.fields(id,knowledgeid,title,knowledgeTitile,description,cardorder).contentcard(all)", - "view": "json", - "token": "4faa8662c59590c6f43ae9fe5b002b42", - "_time": strconv.FormatInt(time.Now().UnixMilli(), 10), - }).Get(ApiChapterCards) - if err != nil { - return err - } - - body = resp.String() - if strings.Contains(body, "请输入验证码") { - logger.Logger.Warn("触发验证码,正在进行AI智能识别绕过.....") - e := retry.Do(func() error { - bytes, e := ApiVerifyCodeBytes(c.client) - if e != nil { - return e - } - codeResult := captcha.ApiSemiOCRVerification(bytes, 1, 23) - time.Sleep(5 * time.Second) - if ApiPassVerifyCode(c.client, codeResult, 5) { - return nil - } - return errors2.NewBusinessError(503, "验证码识别失败") - }, retry.Attempts(5), retry.Delay(5*time.Second)) - return e - } - - statusCode := resp.StatusCode() - if statusCode == 500 || statusCode == 202 || statusCode == 400 || statusCode == 403 { - return errors2.NewBusinessError(statusCode, fmt.Sprintf("获取任务点失败,状态码: %d", statusCode)) - } - return nil - }, retry.Attempts(3), retry.Delay(5*time.Second)) - - if err != nil { - return nil, nil, err - } - - var apiResp entity.CardAPIResponse - if err := json.Unmarshal([]byte(body), &apiResp); err != nil { - return nil, nil, err - } - - if len(apiResp.Data) == 0 { - return nil, nil, nil - } - - dataItem := apiResp.Data[0] - cards := dataItem.Card.Data - - // 收集所有任务点(一个 card 可能有多个任务点) - var points []entity.ChapterPoint - cpiStr := strconv.Itoa(course.Cpi) - - for cardIndex, card := range cards { - logger.Logger.Debug("处理卡片", - zap.Int("cardIndex", cardIndex), - zap.Int("cardID", card.ID), - zap.Int("cardKnowledgeID", card.KnowledgeID), - zap.String("cardTitle", card.Title), - zap.Int("descriptionLength", len(card.Description))) - - if card.Description == "" { - continue - } - iframes, err := c.parseIframeData(card.Description) - if err != nil { - logger.Logger.Debug("解析卡片失败", zap.Error(err)) - continue - } - - for _, iframe := range iframes { - if !iframe.HasData { - continue - } - pointType, ok := iframe.Other["module"] - logger.Logger.Debug("解析到任务点", zap.String("module", pointType), zap.Bool("hasModule", ok), zap.Any("data", iframe.Data)) - if !ok { - continue - } - - // 创建基础 point - point := entity.ChapterPoint{ - CardIndex: cardIndex, - KnowledgeID: card.KnowledgeID, - CourseID: course.CourseID, - ClassID: course.Key, - Cpi: cpiStr, - } - - switch pointType { - case "insertvideo", "insertaudio": - objectID, ok := iframe.Data["objectid"].(string) - if !ok || objectID == "" { - continue - } - jobID, _ := iframe.Data["_jobid"].(string) - point.Type = entity.ChapterPointTypeVideo - point.ObjectID = objectID - point.JobID = jobID - - // 参考 yatori-go-core:在这里调用 FetchChapterCards2 获取 otherInfo 等参数 - cords2, err := c.FetchChapterCards2(ctx, course, card.KnowledgeID) - if err == nil && cords2 != nil { - if attachments, ok := cords2["attachments"].([]interface{}); ok { - for _, att := range attachments { - if attMap, ok := att.(map[string]interface{}); ok { - // 通过 objectId 匹配 - attObjectID, _ := attMap["objectId"].(string) - if attObjectID == "" { - // 尝试从 property 中获取 - if prop, ok := attMap["property"].(map[string]interface{}); ok { - attObjectID, _ = prop["objectid"].(string) - } - } - if attObjectID == objectID { - // 获取 otherInfo(参考 yatori-go-core:需要截断 & 之前的部分) - if otherInfo, ok := attMap["otherInfo"].(string); ok && len(otherInfo) > 80 { - // 截断 & 之前的部分 - parts := strings.SplitN(otherInfo, "&", 2) - if len(parts) > 0 { - point.OtherInfo = parts[0] - } - } - // 获取 jobid - if jid, ok := attMap["jobid"].(string); ok && jid != "" { - point.JobID = jid - } - // 获取 playTime - if playTime, ok := attMap["playTime"].(float64); ok { - point.PlayTime = int(playTime) / 1000 - } - // 获取 attDurationEnc - if ade, ok := attMap["attDurationEnc"].(string); ok { - point.AttDurationEnc = ade - } - // 获取 videoFaceCaptureEnc - if vfce, ok := attMap["videoFaceCaptureEnc"].(string); ok { - point.VideoFaceCaptureEnc = vfce - } - // 获取 isPassed - if isPassed, ok := attMap["isPassed"].(bool); ok { - point.IsPassed = isPassed - } - // 获取 RT(默认 0.9) - point.RT = 0.9 - if prop, ok := attMap["property"].(map[string]interface{}); ok { - if rtObj, ok := prop["rt"]; ok { - switch v := rtObj.(type) { - case float64: - point.RT = v - case string: - if rt, err := strconv.ParseFloat(v, 64); err == nil { - point.RT = rt - } - } - } - } - logger.Logger.Debug("从 FetchChapterCards2 获取到视频参数", - zap.String("objectID", objectID), - zap.String("otherInfo", point.OtherInfo), - zap.String("attDurationEnc", point.AttDurationEnc), - zap.String("videoFaceCaptureEnc", point.VideoFaceCaptureEnc), - zap.Float64("RT", point.RT)) - break - } - } - } - } - // 获取 defaults 中的 fid 和 cpi - if defaults, ok := cords2["defaults"].(map[string]interface{}); ok { - if fid, ok := defaults["fid"].(string); ok { - point.FID, _ = strconv.Atoi(fid) - } - // 从 defaults 中获取 cpi(可能是 float64 或 string) - if cpiVal, ok := defaults["cpi"]; ok { - switch v := cpiVal.(type) { - case float64: - point.Cpi = strconv.FormatInt(int64(v), 10) - case string: - point.Cpi = v - } - } - // 从 defaults 中获取 userid - if userid, ok := defaults["userid"].(string); ok { - c.userID = userid - } - } - } - - case "insertdoc", "document": - objectID, _ := iframe.Data["objectid"].(string) - jobID, _ := iframe.Data["_jobid"].(string) - if jobID == "" { - continue - } - point.Type = entity.ChapterPointTypeDocument - point.ObjectID = objectID - point.JobID = jobID - point.DocumentType = "document" - - case "insertbook": - jobID, _ := iframe.Data["_jobid"].(string) - if jobID == "" { - continue - } - point.Type = entity.ChapterPointTypeDocument - point.JobID = jobID - point.DocumentType = "insertbook" - - case "work", "insertwork": - workID, _ := iframe.Data["workid"].(string) - schoolID, _ := iframe.Data["schoolid"].(string) - jobID, _ := iframe.Data["_jobid"].(string) - if workID == "" || jobID == "" { - continue - } - if schoolID == "" { - schoolID = "0" - } - point.Type = entity.ChapterPointTypeWork - point.WorkID = workID - point.SchoolID = schoolID - point.JobID = jobID - - case "hyperlink", "insertlink": - jobID, _ := iframe.Data["_jobid"].(string) - if jobID == "" { - continue - } - linkType := 0 - if lt, ok := iframe.Data["linkType"].(float64); ok { - linkType = int(lt) - } - point.Type = entity.ChapterPointTypeHyperlink - point.JobID = jobID - point.LinkType = linkType - - case "insertlive": - jobID, _ := iframe.Data["_jobid"].(string) - if jobID == "" { - continue - } - title, _ := iframe.Data["title"].(string) - liveStatus, _ := iframe.Data["liveStatus"].(string) - streamName, _ := iframe.Data["streamName"].(string) - vdoid, _ := iframe.Data["vdoid"].(string) - liveId := "" - if lid, ok := iframe.Data["liveId"].(float64); ok { - liveId = strconv.FormatInt(int64(lid), 10) - } - live, _ := iframe.Data["live"].(bool) - - point.Type = entity.ChapterPointTypeLive - point.JobID = jobID - point.Title = title - point.LiveStatusStr = liveStatus - point.StreamName = streamName - point.Vdoid = vdoid - point.LiveId = liveId - point.Live = live - point.Module = pointType - point.UserId = c.userID - - default: - logger.Logger.Debug("未知的任务点类型", zap.String("type", pointType)) - continue - } - - points = append(points, point) - } - } - - return cards, points, nil -} - -// parseIframeData 从HTML中解析iframe数据 -func (c *XuexitongCourse) parseIframeData(htmlString string) ([]entity.IframeAttributes, error) { - node, err := html.Parse(strings.NewReader(htmlString)) - if err != nil { - return nil, err - } - - var iframes []entity.IframeAttributes - var traverse func(n *html.Node) - traverse = func(n *html.Node) { - if n.Type == html.ElementNode && n.Data == "iframe" { - attrs := entity.IframeAttributes{ - Other: make(map[string]string), - } - hasData := false - for _, attr := range n.Attr { - if attr.Key == "data" && strings.TrimSpace(attr.Val) != "" { - hasData = true - cleanedData := strings.ReplaceAll(attr.Val, """, "\"") - cleanedData = regexp.MustCompile(`\s+`).ReplaceAllString(cleanedData, "") - if err := json.Unmarshal([]byte(cleanedData), &attrs.Data); err != nil { - logger.Logger.Debug("Failed to decode JSON", zap.Error(err)) - } - } else { - attrs.Other[attr.Key] = attr.Val - } - } - attrs.HasData = hasData - iframes = append(iframes, attrs) - } - for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { - traverse(ch) - } - } - traverse(node) - return iframes, nil +// cleanHTMLTags 清理HTML标签 +func cleanHTMLTags(s string) string { + re := regexp.MustCompile(`<[^>]*>`) + s = re.ReplaceAllString(s, "") + s = regexp.MustCompile(`\s+`).ReplaceAllString(s, " ") + return strings.TrimSpace(s) } // extractJSONObject 从字符串中提取 JSON 对象,通过匹配括号来找到正确的结束位置 func extractJSONObject(content, prefix string) string { - // 找到前缀位置 idx := strings.Index(content, prefix) if idx == -1 { logger.Logger.Debug("extractJSONObject: 未找到前缀", zap.String("prefix", prefix)) return "" } - // 找到 = 后的 { 开始位置 startIdx := strings.Index(content[idx:], "{") if startIdx == -1 { logger.Logger.Debug("extractJSONObject: 未找到 { 开始位置") @@ -742,7 +247,6 @@ func extractJSONObject(content, prefix string) string { zap.Int("prefixIdx", idx), zap.Int("startIdx", startIdx)) - // 通过计数括号找到匹配的结束位置 braceCount := 0 inString := false escape := false @@ -786,2115 +290,3 @@ func extractJSONObject(content, prefix string) string { zap.Int("finalBraceCount", braceCount)) return "" } - -// PageMobileChapterCard 获取章节卡片页面(SSR方式,用于获取附件信息) -func (c *XuexitongCourse) PageMobileChapterCard(ctx context.Context, course entity.XueXiTCourse, knowledgeID, cardIndex int) (*entity.AttachmentSetting, string, error) { - classID, _ := strconv.Atoi(course.Key) - courseID, _ := strconv.Atoi(course.CourseID) - - params := url.Values{} - params.Add("clazzid", strconv.Itoa(classID)) - params.Add("courseid", strconv.Itoa(courseID)) - params.Add("knowledgeid", strconv.Itoa(knowledgeID)) - params.Add("num", strconv.Itoa(cardIndex)) - params.Add("isPhone", "1") - params.Add("control", "true") - params.Add("cpi", strconv.Itoa(course.Cpi)) - - requestURL := PageChapterCard + "?" + params.Encode() - logger.Logger.Debug("PageMobileChapterCard 请求", - zap.String("url", requestURL), - zap.Int("knowledgeID", knowledgeID), - zap.Int("cardIndex", cardIndex)) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - }).Get(requestURL) - if err != nil { - return err - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - logger.Logger.Error("PageMobileChapterCard 请求失败", zap.Error(err)) - return nil, "", err - } - - // 打印响应长度和前500字符用于调试 - logger.Logger.Debug("PageMobileChapterCard 响应", - zap.Int("bodyLength", len(body)), - zap.Bool("containsAttachmentSetting", strings.Contains(body, "window.AttachmentSetting")), - zap.Bool("containsBlankTips", strings.Contains(body, "blankTips")), - zap.String("bodyPreview", body[:min(len(body), 500)])) - - // 使用 HTML 解析器提取 script 内容(参考 yatori-go-core 的实现) - doc, err := html.Parse(strings.NewReader(body)) - if err != nil { - logger.Logger.Error("解析 HTML 失败", zap.Error(err)) - return nil, "", err - } - - // 参考 yatori-go-core 的实现:找到第一个 type="text/javascript" 的 script - var scriptContent string - var scriptCount int - var traverse func(*html.Node) - traverse = func(n *html.Node) { - if scriptContent != "" { - return // 已找到,停止遍历 - } - if n.Type == html.ElementNode && n.Data == "script" { - // 检查是否有 type="text/javascript" 属性 - hasTypeAttr := false - for _, attr := range n.Attr { - if attr.Key == "type" && strings.TrimSpace(attr.Val) == "text/javascript" { - hasTypeAttr = true - break - } - } - - if hasTypeAttr { - scriptCount++ - // 获取 script 标签的文本内容 - if n.FirstChild != nil && n.FirstChild.Type == html.TextNode { - scriptContent = strings.TrimSpace(n.FirstChild.Data) - logger.Logger.Debug("找到 script 标签", - zap.Int("scriptIndex", scriptCount), - zap.Int("contentLength", len(scriptContent)), - zap.Bool("hasAttachmentSetting", strings.Contains(scriptContent, "window.AttachmentSetting"))) - return // 参考实现:找到第一个就返回 - } - } - } - - for ch := n.FirstChild; ch != nil; ch = ch.NextSibling { - traverse(ch) - } - } - traverse(doc) - - logger.Logger.Debug("HTML 解析完成", zap.Int("scriptCount", scriptCount), zap.Bool("hasScriptContent", scriptContent != "")) - - // 检查是否包含 window.AttachmentSetting - if scriptContent == "" || !strings.Contains(scriptContent, "window.AttachmentSetting") { - // 检查是否是章节未开放 - var blankTips string - traverse = func(n *html.Node) { - if n.Type == html.ElementNode && n.Data == "p" { - for _, attr := range n.Attr { - if attr.Key == "class" && strings.Contains(attr.Val, "blankTips") { - if n.FirstChild != nil { - blankTips = strings.TrimSpace(n.FirstChild.Data) - } - return - } - } - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - traverse(c) - } - } - traverse(doc) - - if blankTips != "" { - logger.Logger.Warn("章节提示信息", zap.String("blankTips", blankTips)) - } - - // 尝试直接从 body 中搜索 - if strings.Contains(body, "window.AttachmentSetting") { - scriptContent = body - logger.Logger.Debug("从原始 body 中找到 window.AttachmentSetting") - } else { - logger.Logger.Warn("未找到 window.AttachmentSetting", - zap.Int("knowledgeID", knowledgeID), - zap.Int("cardIndex", cardIndex), - zap.String("blankTips", blankTips)) - return nil, "", nil - } - } - - // 使用正则提取 JSON(参考实现使用的方式) - re := regexp.MustCompile(`window\.AttachmentSetting\s*=\s*(\{.*?\});`) - matches := re.FindStringSubmatch(scriptContent) - var jsonStr string - if len(matches) > 1 { - jsonStr = matches[1] - logger.Logger.Debug("正则提取 JSON 成功", zap.Int("jsonLength", len(jsonStr))) - } else { - // 如果正则失败,尝试括号匹配方式 - jsonStr = extractJSONObject(scriptContent, "window.AttachmentSetting") - if jsonStr == "" { - logger.Logger.Warn("无法提取 JSON", - zap.Int("scriptContentLength", len(scriptContent))) - return nil, "", nil - } - logger.Logger.Debug("括号匹配提取 JSON 成功", zap.Int("jsonLength", len(jsonStr))) - } - - var attachment entity.AttachmentSetting - if err := json.Unmarshal([]byte(jsonStr), &attachment); err != nil { - logger.Logger.Error("解析 AttachmentSetting 失败", - zap.Error(err), - zap.String("jsonStr", jsonStr[:min(len(jsonStr), 500)])) - return nil, "", err - } - - logger.Logger.Debug("解析 AttachmentSetting 成功", zap.Int("attachmentsCount", len(attachment.Attachments))) - - // 调试:打印每个附件的关键信息 - for idx, att := range attachment.Attachments { - property, _ := att["property"].(map[string]interface{}) - objectid := "" - if property != nil { - if oid, ok := property["objectid"].(string); ok { - objectid = oid - } - } - otherInfo, _ := att["otherInfo"].(string) - attDurationEnc, _ := att["attDurationEnc"].(string) - videoFaceCaptureEnc, _ := att["videoFaceCaptureEnc"].(string) - logger.Logger.Debug("附件信息", - zap.Int("index", idx), - zap.String("objectid", objectid), - zap.Bool("hasOtherInfo", otherInfo != ""), - zap.Bool("hasAttDurationEnc", attDurationEnc != ""), - zap.Bool("hasVideoFaceCaptureEnc", videoFaceCaptureEnc != "")) - } - - // 截取 enc - reEnc := regexp.MustCompile(``) - matchesEnc := reEnc.FindStringSubmatch(body) - enc := "" - if len(matchesEnc) > 1 { - enc = matchesEnc[1] - } - - return &attachment, enc, nil -} - -// FetchChapterCards2 获取章节卡片(新接口,用于获取 otherInfo 等) -func (c *XuexitongCourse) FetchChapterCards2(ctx context.Context, course entity.XueXiTCourse, knowledgeID int) (map[string]interface{}, error) { - urlStr := fmt.Sprintf("%s?clazzid=%s&courseid=%s&knowledgeid=%d&num=0&ut=s&cpi=%d&v=2025-0424-1038-3&mooc2=1&isMicroCourse=false&editorPreview=0", - ApiChapterCards2, course.Key, course.CourseID, knowledgeID, course.Cpi) - - logger.Logger.Debug("FetchChapterCards2 请求", - zap.String("url", urlStr), - zap.Int("knowledgeID", knowledgeID)) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - }).Get(urlStr) - if err != nil { - return err - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - logger.Logger.Error("FetchChapterCards2 请求失败", zap.Error(err)) - return nil, err - } - - logger.Logger.Debug("FetchChapterCards2 响应", - zap.Int("bodyLength", len(body)), - zap.Bool("containsMArg", strings.Contains(body, "mArg"))) - - // 从页面中提取 mArg JSON - re := regexp.MustCompile(`mArg = ([^;]{6,})`) - matches := re.FindStringSubmatch(body) - if len(matches) <= 1 { - logger.Logger.Warn("FetchChapterCards2 未找到 mArg") - return nil, nil - } - - logger.Logger.Debug("FetchChapterCards2 提取到 mArg", zap.Int("jsonLength", len(matches[1]))) - - var result map[string]interface{} - if err := json.Unmarshal([]byte(matches[1]), &result); err != nil { - logger.Logger.Error("FetchChapterCards2 解析 JSON 失败", zap.Error(err)) - return nil, err - } - - // 打印 attachments 数量 - if attachments, ok := result["attachments"].([]interface{}); ok { - logger.Logger.Debug("FetchChapterCards2 解析成功", - zap.Int("attachmentsCount", len(attachments))) - } - - return result, nil -} - -// VideoDtoFetch 获取视频详细信息 -func (c *XuexitongCourse) VideoDtoFetch(ctx context.Context, p *entity.PointVideoDto) error { - params := url.Values{} - params.Set("k", strconv.Itoa(p.FID)) - params.Set("flag", "normal") - params.Set("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10)) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - }).Get(fmt.Sprintf("%s/%s?%s", ApiVideoStatus, p.ObjectID, params.Encode())) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "获取视频信息失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return err - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(body), &result); err != nil { - return err - } - - if dtoken, ok := result["dtoken"].(string); ok { - p.DToken = dtoken - } - if duration, ok := result["duration"].(float64); ok { - p.Duration = int(duration) - } - if filename, ok := result["filename"].(string); ok { - if decoded, err := url.QueryUnescape(filename); err == nil { - p.Title = decoded - } else { - p.Title = filename - } - } - if status, ok := result["status"].(string); ok && status == "success" { - return nil - } - - return errors2.NewBusinessError(503, "视频状态异常") -} - -// AttachmentsDetectionVideo 从附件设置中检测并填充视频信息(旧版,保留兼容) -func (c *XuexitongCourse) AttachmentsDetectionVideo(attachment *entity.AttachmentSetting, p *entity.PointVideoDto) error { - if attachment == nil { - return errors2.NewBusinessError(503, "附件设置为空") - } - - for _, att := range attachment.Attachments { - property, ok := att["property"].(map[string]interface{}) - if !ok { - continue - } - objectid, _ := property["objectid"].(string) - if objectid != p.ObjectID { - continue - } - - // 获取 jobid - if jobid, ok := property["jobid"].(string); ok { - p.JobID = jobid - } else if jobidFloat, ok := property["jobid"].(float64); ok { - p.JobID = strconv.FormatFloat(jobidFloat, 'f', -1, 64) - } - - // 获取 otherInfo(截断 &,只取前面的部分) - if otherInfo, ok := att["otherInfo"].(string); ok { - parts := strings.SplitN(otherInfo, "&", 2) - if len(parts) > 0 { - p.OtherInfo = parts[0] - } - } - - // 获取 isPassed - if isPassed, ok := att["isPassed"].(bool); ok { - p.IsPassed = isPassed - } - - // 获取 rt - if rtObj, ok := property["rt"]; ok { - switch v := rtObj.(type) { - case float64: - p.RT = v - case string: - if rt, err := strconv.ParseFloat(v, 64); err == nil { - p.RT = rt - } else { - p.RT = 0.9 - } - } - } else { - p.RT = 0.9 - } - - // 获取 playTime - if playTime, ok := att["playTime"].(float64); ok { - p.PlayTime = int(playTime) / 1000 - } - - // 获取 mid - if mid, ok := att["mid"].(string); ok { - p.Mid = mid - } - - // 获取 randomCaptureTime - if rct, ok := att["randomCaptureTime"].(string); ok { - p.RandomCaptureTime = rct - } - - // 获取 attDurationEnc - if ade, ok := att["attDurationEnc"].(string); ok { - p.AttDurationEnc = ade - } - - // 获取 videoFaceCaptureEnc - if vfce, ok := att["videoFaceCaptureEnc"].(string); ok { - p.VideoFaceCaptureEnc = vfce - } - - break - } - - // 获取 defaults 中的信息 - if defaults := attachment.Defaults; defaults != nil { - if fid, ok := defaults["fid"].(string); ok { - p.FID, _ = strconv.Atoi(fid) - } - if userid, ok := defaults["userid"].(string); ok { - c.userID = userid - } - } - - return nil -} - -// AttachmentsDetectionPoint 从附件设置中检测并填充任务点信息(统一版) -func (c *XuexitongCourse) AttachmentsDetectionPoint(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) error { - if attachment == nil { - return errors2.NewBusinessError(503, "附件设置为空") - } - - switch p.Type { - case entity.ChapterPointTypeVideo: - c.attachmentDetectVideo(attachment, p) - case entity.ChapterPointTypeDocument: - c.attachmentDetectDocument(attachment, p) - case entity.ChapterPointTypeWork: - c.attachmentDetectWork(attachment, p) - case entity.ChapterPointTypeHyperlink: - c.attachmentDetectHyperlink(attachment, p) - case entity.ChapterPointTypeLive: - c.attachmentDetectLive(attachment, p) - } - - return nil -} - -func (c *XuexitongCourse) attachmentDetectVideo(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) { - logger.Logger.Debug("attachmentDetectVideo 开始匹配", - zap.String("targetObjectID", p.ObjectID), - zap.Int("attachmentsCount", len(attachment.Attachments))) - - found := false - for idx, att := range attachment.Attachments { - property, ok := att["property"].(map[string]interface{}) - if !ok { - continue - } - // 防止一个节点多种任务点类型,其他 property 中没有 objectid - objectid := property["objectid"] - if objectid == nil { - continue - } - objectidStr, _ := objectid.(string) - - logger.Logger.Debug("检查附件", - zap.Int("index", idx), - zap.String("objectid", objectidStr), - zap.String("targetObjectID", p.ObjectID)) - - if objectidStr != p.ObjectID { - continue - } - - found = true - logger.Logger.Debug("找到匹配的附件", zap.Int("index", idx)) - - // 获取 jobid - if jobid, ok := property["jobid"].(string); ok { - p.JobID = jobid - } else if jobidFloat, ok := property["jobid"].(float64); ok { - p.JobID = strconv.FormatFloat(jobidFloat, 'f', -1, 64) - } - - // otherInfo 需要截断 &,只取前面的部分 - if otherInfo, ok := att["otherInfo"].(string); ok { - parts := strings.SplitN(otherInfo, "&", 2) - if len(parts) > 0 { - p.OtherInfo = parts[0] - } - logger.Logger.Debug("获取到 otherInfo", zap.String("otherInfo", p.OtherInfo)) - } - - if isPassed, ok := att["isPassed"].(bool); ok { - p.IsPassed = isPassed - } - - // 获取 rt - if rtObj, ok := property["rt"]; ok { - switch v := rtObj.(type) { - case float64: - p.RT = v - case string: - if rt, err := strconv.ParseFloat(v, 64); err == nil { - p.RT = rt - } else { - p.RT = 0.9 - } - } - } else { - p.RT = 0.9 - } - - if playTime, ok := att["playTime"].(float64); ok { - p.PlayTime = int(playTime) / 1000 - } - if mid, ok := att["mid"].(string); ok { - p.Mid = mid - } - if rct, ok := att["randomCaptureTime"].(string); ok { - p.RandomCaptureTime = rct - } - if ade, ok := att["attDurationEnc"].(string); ok { - p.AttDurationEnc = ade - logger.Logger.Debug("获取到 attDurationEnc", zap.String("attDurationEnc", ade)) - } - if vfce, ok := att["videoFaceCaptureEnc"].(string); ok { - p.VideoFaceCaptureEnc = vfce - logger.Logger.Debug("获取到 videoFaceCaptureEnc", zap.String("videoFaceCaptureEnc", vfce)) - } - break - } - - if !found { - logger.Logger.Warn("未找到匹配的视频附件", zap.String("objectID", p.ObjectID)) - } - - if defaults := attachment.Defaults; defaults != nil { - if fid, ok := defaults["fid"].(string); ok { - p.FID, _ = strconv.Atoi(fid) - } - if userid, ok := defaults["userid"].(string); ok { - c.userID = userid - } - } -} - -func (c *XuexitongCourse) attachmentDetectDocument(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) { - for _, att := range attachment.Attachments { - property, ok := att["property"].(map[string]interface{}) - if !ok { - continue - } - - jobid, _ := property["jobid"].(string) - if jobid != p.JobID && p.JobID != "" { - continue - } - - if name, ok := property["name"].(string); ok { - p.Title = name - } - if bookname, ok := property["bookname"].(string); ok { - p.Title = bookname - } - if jid, ok := property["jobid"].(string); ok { - p.JobID = jid - } - if jtoken, ok := att["jtoken"].(string); ok { - p.Jtoken = jtoken - } - if isJob, ok := att["job"].(bool); ok { - p.IsJob = isJob - } - break - } -} - -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 - } - } -} - -func (c *XuexitongCourse) attachmentDetectHyperlink(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) { - for _, att := range attachment.Attachments { - jobid, _ := att["jobid"].(string) - if jobid != p.JobID { - continue - } - - property, ok := att["property"].(map[string]interface{}) - if !ok { - continue - } - - if title, ok := property["title"].(string); ok { - p.Title = title - } - if jtoken, ok := att["jtoken"].(string); ok { - p.Jtoken = jtoken - } - if isJob, ok := att["job"].(bool); ok { - p.IsJob = isJob - } - break - } -} - -func (c *XuexitongCourse) attachmentDetectLive(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) { - for _, att := range attachment.Attachments { - jobid, _ := att["jobid"].(string) - if jobid != p.JobID { - continue - } - - if authEnc, ok := att["authEnc"].(string); ok { - p.AuthEnc = authEnc - } - if liveDragEnc, ok := att["liveDragEnc"].(string); ok { - p.LiveDragEnc = liveDragEnc - } - if liveSetEnc, ok := att["liveSetEnc"].(string); ok { - p.LiveSetEnc = liveSetEnc - } - if otherInfo, ok := att["otherInfo"].(string); ok { - p.OtherInfo = otherInfo - } - if enc, ok := att["enc"].(string); ok { - p.Enc = enc - } - if liveSwDsEnc, ok := att["liveSwDsEnc"].(string); ok { - p.LiveSwDsEnc = liveSwDsEnc - } - if isJob, ok := att["job"].(bool); ok { - p.IsJob = isJob - } - if aid, ok := att["aid"].(float64); ok { - p.Aid = strconv.FormatInt(int64(aid), 10) - } - break - } -} - -// AttachmentsDetectionDocument 从附件设置中检测并填充文档信息 -func (c *XuexitongCourse) AttachmentsDetectionDocument(attachment *entity.AttachmentSetting, p *entity.PointDocumentDto) error { - if attachment == nil { - return errors2.NewBusinessError(503, "附件设置为空") - } - - for _, att := range attachment.Attachments { - typeStr := "" - if t, ok := att["type"].(string); ok { - typeStr = t - } else if property, ok := att["property"].(map[string]interface{}); ok { - if module, ok := property["module"].(string); ok { - typeStr = module - } - } - - property, ok := att["property"].(map[string]interface{}) - if !ok { - continue - } - - jobid, _ := property["jobid"].(string) - if jobid != p.JobID && p.JobID != "" { - continue - } - - if name, ok := property["name"].(string); ok { - p.Title = name - } - if bookname, ok := property["bookname"].(string); ok { - p.Title = bookname - } - if jid, ok := property["jobid"].(string); ok { - p.JobID = jid - } - if jtoken, ok := att["jtoken"].(string); ok { - p.Jtoken = jtoken - } - if isJob, ok := att["job"].(bool); ok { - p.IsJob = isJob - } - - if typeStr == "document" || typeStr == "insertdoc" { - p.Type = "document" - } else if typeStr == "insertbook" { - p.Type = "insertbook" - } - - break - } - - return nil -} - -// DocumentDtoReadingReport 提交文档阅读完成 -func (c *XuexitongCourse) DocumentDtoReadingReport(ctx context.Context, p *entity.PointDocumentDto) (string, error) { - params := url.Values{} - params.Add("jobid", p.JobID) - params.Add("knowledgeid", strconv.Itoa(p.KnowledgeID)) - params.Add("courseid", p.CourseID) - params.Add("clazzid", p.ClassID) - params.Add("jtoken", p.Jtoken) - params.Add("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10)) - - apiUrl := ApiDocumentReport - if p.Type == "insertbook" { - apiUrl = ApiDocumentBook - } - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - }).Get(apiUrl + "?" + params.Encode()) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "提交文档阅读失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// AutoPlayVideoByPoint 自动播放视频(使用统一的 ChapterPoint) -func (c *XuexitongCourse) AutoPlayVideoByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error { - // 获取视频详细信息 - if err := c.VideoDtoFetchByPoint(ctx, p); err != nil { - return fmt.Errorf("获取视频信息失败: %w", err) - } - - if p.Duration <= 0 { - logger.Logger.Warn("视频时长为0,跳过", zap.String("jobID", p.JobID)) - return nil - } - - if p.IsPassed { - return nil - } - - logger.Logger.Info("开始刷视频", - zap.String("jobID", p.JobID), - zap.Int("duration", p.Duration), - zap.Int("playTime", p.PlayTime), - zap.String("title", p.Title), - zap.String("userID", c.userID), - zap.String("cpi", p.Cpi), - zap.Int("FID", p.FID), - zap.String("otherInfo", p.OtherInfo)) - - playingTime := p.PlayTime - // 如果已完成但 playTime == duration,重置为 0 - if !p.IsPassed && p.PlayTime == p.Duration { - playingTime = 0 - } - - selectSec := 58 // 参考 yatori-go-core:每 58 秒提交一次 - isFirstSubmit := true - - for { - var playReport string - var err error - - // 提交进度 - isdrag := 0 - if isFirstSubmit && playingTime == p.PlayTime { - isdrag = 3 // 首次提交使用 isdrag=3 - } - playReport, err = c.VideoSubmitStudyTimeByPoint(ctx, p, playingTime, isdrag) - isFirstSubmit = false - - if err != nil { - logger.Logger.Error("提交视频进度失败", zap.Error(err)) - return err - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(playReport), &result); err == nil { - if isPassed, ok := result["isPassed"].(bool); ok && isPassed { - logger.Logger.Info("视频播放完成", zap.String("jobID", p.JobID)) - c.onTaskComplete() - return nil - } - } - - logger.Logger.Info("视频播放", zap.Int("playingTime", playingTime), zap.Int("duration", p.Duration)) - - // 计算下次提交时间和播放进度 - if p.Duration-playingTime < selectSec && p.Duration != playingTime { - // 剩余时间小于 58 秒,直接跳到结束 - sleepTime := p.Duration - playingTime - playingTime = p.Duration - time.Sleep(time.Duration(sleepTime) * time.Second) - } else if playingTime >= p.Duration { - // 已播放完成但未通过,等待 5 秒后重试 - time.Sleep(5 * time.Second) - } else { - // 正常计时 - playingTime += selectSec - time.Sleep(time.Duration(selectSec) * time.Second) - } - } -} - -// VideoDtoFetchByPoint 获取视频详细信息(使用统一的 ChapterPoint) -func (c *XuexitongCourse) VideoDtoFetchByPoint(ctx context.Context, p *entity.ChapterPoint) error { - params := url.Values{} - params.Set("k", strconv.Itoa(p.FID)) - params.Set("flag", "normal") - params.Set("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10)) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - }).Get(fmt.Sprintf("%s/%s?%s", ApiVideoStatus, p.ObjectID, params.Encode())) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "获取视频信息失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return err - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(body), &result); err != nil { - return err - } - - if dtoken, ok := result["dtoken"].(string); ok { - p.DToken = dtoken - } - if duration, ok := result["duration"].(float64); ok { - p.Duration = int(duration) - } - if filename, ok := result["filename"].(string); ok { - if decoded, err := url.QueryUnescape(filename); err == nil { - p.Title = decoded - } else { - p.Title = filename - } - } - if status, ok := result["status"].(string); ok && status == "success" { - return nil - } - - return errors2.NewBusinessError(503, "视频状态异常") -} - -// VideoSubmitStudyTimeByPoint 提交视频观看进度(使用统一的 ChapterPoint) -func (c *XuexitongCourse) VideoSubmitStudyTimeByPoint(ctx context.Context, p *entity.ChapterPoint, playingTime, isdrag int) (string, error) { - clipTime := fmt.Sprintf("0_%d", p.Duration) - hash := md5.Sum([]byte(fmt.Sprintf("[%s][%s][%s][%s][%d][%s][%d][%s]", - p.ClassID, c.userID, p.JobID, p.ObjectID, playingTime*1000, VideoEncKey, p.Duration*1000, clipTime))) - enc := hex.EncodeToString(hash[:]) - - // 参考 yatori-go-core:使用字符串拼接构建 URL,otherInfo 不需要 URL 编码 - // URL 格式:https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/{cpi}/{dtoken}?... - urlStr := "https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" + p.Cpi + "/" + p.DToken + - "?clazzId=" + p.ClassID + - "&playingTime=" + strconv.Itoa(playingTime) + - "&duration=" + strconv.Itoa(p.Duration) + - "&clipTime=" + clipTime + - "&objectId=" + p.ObjectID + - "&otherInfo=" + p.OtherInfo + - "&courseId=" + p.CourseID + - "&jobid=" + p.JobID + - "&userid=" + c.userID + - "&isdrag=" + strconv.Itoa(isdrag) + - "&view=pc" + - "&enc=" + enc + - "&rt=" + fmt.Sprintf("%.2f", p.RT) + - "&videoFaceCaptureEnc=" + p.VideoFaceCaptureEnc + - "&dtype=Video" + - "&_t=" + strconv.FormatInt(time.Now().UnixMilli(), 10) + - "&attDuration=" + strconv.Itoa(p.Duration) + - "&attDurationEnc=" + p.AttDurationEnc - - logger.Logger.Debug("VideoSubmitStudyTimeByPoint 请求参数", - zap.String("cpi", p.Cpi), - zap.String("dtoken", p.DToken), - zap.String("classID", p.ClassID), - zap.String("courseID", p.CourseID), - zap.String("objectID", p.ObjectID), - zap.String("jobID", p.JobID), - zap.String("userID", c.userID), - zap.Int("playingTime", playingTime), - zap.Int("duration", p.Duration), - zap.Int("isdrag", isdrag), - zap.String("otherInfo", p.OtherInfo), - zap.String("attDurationEnc", p.AttDurationEnc), - zap.String("videoFaceCaptureEnc", p.VideoFaceCaptureEnc), - zap.Float64("RT", p.RT), - zap.Int("FID", p.FID), - zap.String("enc", enc)) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0", - "Content-Type": "application/json", - "Accept": "*/*", - "Host": "mooc1.chaoxing.com", - "Connection": "keep-alive", - "Sec-Fetch-Site": "same-origin", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Dest": "empty", - "Pragma": "no-cache", - "Sec-Ch-Ua-Platform": "Windows", - }).SetCookies([]*http.Cookie{ - {Name: "fanyamoocs", Value: "11401F839C536D9E"}, - {Name: "thirdRegist", Value: "0"}, - {Name: "videojs_id", Value: "1778753"}, - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), fmt.Sprintf("提交视频进度失败,状态码: %d", resp.StatusCode())) - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// AutoReadDocumentByPoint 自动阅读文档(使用统一的 ChapterPoint) -func (c *XuexitongCourse) AutoReadDocumentByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error { - if !p.IsJob { - logger.Logger.Info("文档不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - return nil - } - - logger.Logger.Info("开始刷文档", - zap.String("jobID", p.JobID), - zap.String("title", p.Title)) - - report, err := c.DocumentDtoReadingReportByPoint(ctx, p) - if err != nil { - return fmt.Errorf("提交文档阅读失败: %w", err) - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(report), &result); err == nil { - if status, ok := result["status"].(bool); ok && status { - logger.Logger.Info("文档刷课完成", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - c.onTaskComplete() - return nil - } - if msg, ok := result["msg"].(string); ok { - logger.Logger.Warn("文档无法正常学习", zap.String("msg", msg)) - } - } - - return nil -} - -// DocumentDtoReadingReportByPoint 提交文档阅读完成(使用统一的 ChapterPoint) -func (c *XuexitongCourse) DocumentDtoReadingReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) { - params := url.Values{} - params.Add("jobid", p.JobID) - params.Add("knowledgeid", strconv.Itoa(p.KnowledgeID)) - params.Add("courseid", p.CourseID) - params.Add("clazzid", p.ClassID) - params.Add("jtoken", p.Jtoken) - params.Add("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10)) - - apiUrl := ApiDocumentReport - if p.DocumentType == "insertbook" { - apiUrl = ApiDocumentBook - } - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - }).Get(apiUrl + "?" + params.Encode()) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "提交文档阅读失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// AutoCompleteWorkByPoint 自动完成作业任务点(使用统一的 ChapterPoint) -func (c *XuexitongCourse) AutoCompleteWorkByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error { - if p.IsPassed { - logger.Logger.Info("作业已完成", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - return nil - } - // TODO: 作业需要外部提供答案 - return nil -} - -// AutoCompleteHyperlinkByPoint 自动完成外链任务点(使用统一的 ChapterPoint) -func (c *XuexitongCourse) AutoCompleteHyperlinkByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error { - if !p.IsJob { - logger.Logger.Info("外链不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - return nil - } - - logger.Logger.Info("开始刷外链", - zap.String("jobID", p.JobID), - zap.String("title", p.Title)) - - report, err := c.HyperlinkDtoCompleteReportByPoint(ctx, p) - if err != nil { - return fmt.Errorf("提交外链完成失败: %w", err) - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(report), &result); err == nil { - if status, ok := result["status"].(bool); ok && status { - logger.Logger.Info("外链刷课完成", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - c.onTaskComplete() - return nil - } - if msg, ok := result["msg"].(string); ok { - logger.Logger.Warn("外链无法正常学习", zap.String("msg", msg)) - } - } - - return nil -} - -// HyperlinkDtoCompleteReportByPoint 提交外链完成(使用统一的 ChapterPoint) -func (c *XuexitongCourse) HyperlinkDtoCompleteReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) { - urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/job/hyperlink?jobid=%s&knowledgeid=%d&courseid=%s&clazzid=%s&jtoken=%s&checkMicroTopic=trueµTopicId=undefined&_dc=%d", - p.JobID, p.KnowledgeID, p.CourseID, p.ClassID, p.Jtoken, time.Now().UnixMilli()) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("web"), - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "提交外链完成失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// AutoWatchLiveByPoint 自动观看直播任务点(使用统一的 ChapterPoint) -func (c *XuexitongCourse) AutoWatchLiveByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error { - if !p.IsJob { - logger.Logger.Info("直播不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - return nil - } - - // 获取直播信息 - if err := c.PullLiveInfoByPoint(ctx, p); err != nil { - logger.Logger.Warn("获取直播信息失败", zap.Error(err)) - } - - if p.LiveStatusCode == 0 { - logger.Logger.Info("直播还未开始,跳过", zap.String("title", p.Title)) - return nil - } - - if p.VideoCompletePercent >= 90 { - logger.Logger.Info("直播已完成", zap.String("title", p.Title), zap.Float64("percent", p.VideoCompletePercent)) - return nil - } - - logger.Logger.Info("开始刷直播", - zap.String("jobID", p.JobID), - zap.String("title", p.Title), - zap.Float64("currentPercent", p.VideoCompletePercent)) - - if _, err := c.LiveRelationReportByPoint(ctx, p); err != nil { - logger.Logger.Warn("建立直播连接失败", zap.Error(err)) - } - - for { - report, err := c.LiveSaveTimeReportByPoint(ctx, p) - if err != nil { - logger.Logger.Error("提交直播观看进度失败", zap.Error(err)) - return err - } - - if err := c.PullLiveInfoByPoint(ctx, p); err != nil { - logger.Logger.Warn("更新直播信息失败", zap.Error(err)) - } - - logger.Logger.Debug("直播观看中", - zap.String("title", p.Title), - zap.Float64("percent", p.VideoCompletePercent), - zap.String("report", report)) - - if p.VideoCompletePercent >= 90 { - logger.Logger.Info("直播刷课完成", zap.String("title", p.Title)) - c.onTaskComplete() - break - } - - time.Sleep(30 * time.Second) - } - - return nil -} - -// PullLiveInfoByPoint 获取直播信息(使用统一的 ChapterPoint) -func (c *XuexitongCourse) PullLiveInfoByPoint(ctx context.Context, p *entity.ChapterPoint) error { - urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid=%s&userid=%s&clazzid=%s&knowledgeid=%d&courseid=%s&jobid=%s&ut=s", - p.LiveId, p.UserId, p.ClassID, p.KnowledgeID, p.CourseID, p.JobID) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("web"), - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "获取直播信息失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return err - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(body), &result); err != nil { - return err - } - - if status, ok := result["status"].(bool); ok && status { - if temp, ok := result["temp"].(map[string]interface{}); ok { - if data, ok := temp["data"].(map[string]interface{}); ok { - if percentValue, ok := data["percentValue"].(float64); ok { - p.VideoCompletePercent = percentValue - } - if duration, ok := data["duration"].(float64); ok { - p.VideoDuration = int(duration) - } - if liveStatus, ok := data["liveStatus"].(float64); ok { - p.LiveStatusCode = int(liveStatus) - } - } - } - } - - return nil -} - -// LiveRelationReportByPoint 建立直播连接(使用统一的 ChapterPoint) -func (c *XuexitongCourse) LiveRelationReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) { - urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/mooc-ans/live/relation?courseid=%s&knowledgeid=%d&ut=s&jobid=%s&aid=%s", - p.CourseID, p.KnowledgeID, p.JobID, p.Aid) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("web"), - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "建立直播连接失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// LiveSaveTimeReportByPoint 提交直播观看进度(使用统一的 ChapterPoint) -func (c *XuexitongCourse) LiveSaveTimeReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) { - urlStr := fmt.Sprintf("https://zhibo.chaoxing.com/saveTimePc?streamName=%s&vdoid=%s&userId=%s&isStart=1&t=%d&courseId=%s", - p.StreamName, p.Vdoid, p.UserId, time.Now().UnixMilli(), p.CourseID) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("web"), - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "提交直播观看进度失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// AutoReadDocument 自动阅读文档(刷课) -func (c *XuexitongCourse) AutoReadDocument(ctx context.Context, course entity.XueXiTCourse, p *entity.PointDocumentDto) error { - if !p.IsJob { - logger.Logger.Info("文档不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - return nil - } - - logger.Logger.Info("开始刷文档", - zap.String("jobID", p.JobID), - zap.String("title", p.Title)) - - report, err := c.DocumentDtoReadingReport(ctx, p) - if err != nil { - return fmt.Errorf("提交文档阅读失败: %w", err) - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(report), &result); err == nil { - if status, ok := result["status"].(bool); ok && status { - logger.Logger.Info("文档刷课完成", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - c.onTaskComplete() // 任务点完成回调 - return nil - } - if msg, ok := result["msg"].(string); ok { - logger.Logger.Warn("文档无法正常学习", zap.String("msg", msg)) - } - } - - return nil -} - -// AutoStudyByCourseID 通过课程ID自动刷课(定时任务主入口) -func (c *XuexitongCourse) AutoStudyByCourseID(ctx context.Context, courseID string) error { - logger.Logger.Info(fmt.Sprintf("开始刷课,课程ID: %s", courseID)) - - courses := c.PullCourseList(ctx) - if len(courses) == 0 { - return fmt.Errorf("没有找到任何课程") - } - - var targetCourse *entity.XueXiTCourse - for _, course := range courses { - if course.CourseID == courseID { - targetCourse = &course - break - } - } - - if targetCourse == nil { - return fmt.Errorf("未找到课程ID为 %s 的课程", courseID) - } - - //logger.Logger.Info(fmt.Sprintf("找到课程: %s", targetCourse.CourseName)) - - if !targetCourse.IsStart { - return fmt.Errorf("课程 %s 未开课", targetCourse.CourseName) - } - - if targetCourse.State != 0 { - return fmt.Errorf("课程 %s 已结束", targetCourse.CourseName) - } - - return c.AutoStudyCourse(ctx, *targetCourse) -} - -// AutoStudyAllCourses 自动学习所有课程 -func (c *XuexitongCourse) AutoStudyAllCourses(ctx context.Context) error { - logger.Logger.Info("开始自动刷所有课程任务") - - courses := c.PullCourseList(ctx) - if len(courses) == 0 { - logger.Logger.Warn("没有找到任何课程") - return nil - } - - logger.Logger.Info(fmt.Sprintf("找到 %d 个课程", len(courses))) - - for i, course := range courses { - logger.Logger.Info(fmt.Sprintf("开始刷课 [%d/%d]: %s", i+1, len(courses), course.CourseName)) - - if !course.IsStart { - logger.Logger.Info(fmt.Sprintf("课程 %s 未开课,跳过", course.CourseName)) - continue - } - - if course.State != 0 { - logger.Logger.Info(fmt.Sprintf("课程 %s 已结束,跳过", course.CourseName)) - continue - } - - err := c.AutoStudyCourse(ctx, course) - if err != nil { - logger.Logger.Error(fmt.Sprintf("刷课失败: %s", course.CourseName), zap.Error(err)) - continue - } - - logger.Logger.Info(fmt.Sprintf("完成刷课 [%d/%d]: %s", i+1, len(courses), course.CourseName)) - } - - logger.Logger.Info("所有课程刷课任务完成") - return nil -} - -// AutoStudyCourse 自动学习指定课程的所有章节 -func (c *XuexitongCourse) AutoStudyCourse(ctx context.Context, course entity.XueXiTCourse) error { - logger.Logger.Info(fmt.Sprintf("开始刷课程: %s", course.CourseName)) - - chapters := c.PullCourseChapterList(ctx, course) - if len(chapters) == 0 { - logger.Logger.Warn(fmt.Sprintf("课程 %s 没有章节", course.CourseName)) - return nil - } - - //logger.Logger.Info(fmt.Sprintf("课程 %s 共有 %d 个章节", course.CourseName, len(chapters))) - - // 获取章节任务点状态(会自动更新 chapters 中每个章节的 PointTotal 和 PointFinish) - _, err := c.FetchChapterPointStatus(ctx, course, chapters) - if err != nil { - logger.Logger.Warn("获取章节状态失败", zap.Error(err)) - } - - // 遍历所有章节 - for _, chapter := range chapters { - // 检查时间限制 - if err := c.checkTimeRestriction(); err != nil { - logger.Logger.Info("时间限制,暂停刷课", zap.String("course", course.CourseName)) - return err - } - - //logger.Logger.Info(fmt.Sprintf("章节 [%d/%d]: %s (任务点: %d, 已完成: %d)", - // i+1, len(chapters), chapter.Name, chapter.PointTotal, chapter.PointFinish)) - - if chapter.JobCount > 0 || chapter.PointTotal > chapter.PointFinish { - err := c.AutoStudyChapter(ctx, course, chapter) - if err != nil { - // 如果是时间限制错误,向上传递 - if err == ErrTimeRestriction { - return err - } - logger.Logger.Error(fmt.Sprintf("刷章节失败: %s", chapter.Name), zap.Error(err)) - continue - } - } - } - - logger.Logger.Info(fmt.Sprintf("课程完成: %s", course.CourseName)) - return nil -} - -// AutoStudyChapter 自动学习指定章节的所有任务点 -func (c *XuexitongCourse) AutoStudyChapter(ctx context.Context, course entity.XueXiTCourse, chapter entity.XueXiTCourseChapter) error { - // 前置调用 - if err := c.EnterChapterForwardCall(ctx, course, chapter.ID); err != nil { - logger.Logger.Warn("EnterChapterForwardCall失败", zap.Error(err)) - } - - // 获取章节任务点(points 通过 CardIndex 与 cards 关联) - // FetchChapterCards 内部已经调用 FetchChapterCards2 获取了 otherInfo 等参数 - cards, points, err := c.FetchChapterCards(ctx, course, chapter.ID) - if err != nil { - return fmt.Errorf("获取章节任务点失败: %w", err) - } - - if len(cards) == 0 || len(points) == 0 { - logger.Logger.Info(fmt.Sprintf("章节 %s 没有任务点", chapter.Name)) - return nil - } - - // 打印视频任务点的关键参数(调试用) - for i := range points { - if points[i].Type == entity.ChapterPointTypeVideo { - logger.Logger.Debug("视频任务点参数", - zap.String("objectID", points[i].ObjectID), - zap.String("jobID", points[i].JobID), - zap.String("otherInfo", points[i].OtherInfo), - zap.String("attDurationEnc", points[i].AttDurationEnc), - zap.String("videoFaceCaptureEnc", points[i].VideoFaceCaptureEnc), - zap.Int("FID", points[i].FID)) - } - } - - // 按顺序处理任务点 - for i := range points { - // 检查时间限制 - if err := c.checkTimeRestriction(); err != nil { - logger.Logger.Info("时间限制,暂停刷课", zap.String("chapter", chapter.Name)) - return err - } - - point := &points[i] - if point.Type == 0 { - continue - } - - switch point.Type { - case entity.ChapterPointTypeVideo: - if err := c.AutoPlayVideoByPoint(ctx, course, point); err != nil { - logger.Logger.Error("视频刷课失败", zap.Error(err), zap.String("title", point.Title)) - } - case entity.ChapterPointTypeDocument: - if err := c.AutoReadDocumentByPoint(ctx, course, point); err != nil { - logger.Logger.Error("文档刷课失败", zap.Error(err), zap.String("title", point.Title)) - } - case entity.ChapterPointTypeWork: - if err := c.AutoCompleteWorkByPoint(ctx, course, point); err != nil { - logger.Logger.Error("作业刷课失败", zap.Error(err), zap.String("title", point.Title)) - } - case entity.ChapterPointTypeHyperlink: - if err := c.AutoCompleteHyperlinkByPoint(ctx, course, point); err != nil { - logger.Logger.Error("外链刷课失败", zap.Error(err), zap.String("title", point.Title)) - } - case entity.ChapterPointTypeLive: - if err := c.AutoWatchLiveByPoint(ctx, course, point); err != nil { - logger.Logger.Error("直播刷课失败", zap.Error(err), zap.String("title", point.Title)) - } - } - } - - return nil -} - -// AttachmentsDetectionWork 从附件设置中检测并填充作业信息 -func (c *XuexitongCourse) AttachmentsDetectionWork(attachment *entity.AttachmentSetting, p *entity.PointWorkDto) error { - if attachment == nil { - return nil - } - - 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 - } - - // 获取 defaults 中的信息 - 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 - } - } - - return nil -} - -// AttachmentsDetectionHyperlink 从附件设置中检测并填充外链信息 -func (c *XuexitongCourse) AttachmentsDetectionHyperlink(attachment *entity.AttachmentSetting, p *entity.PointHyperlinkDto) error { - if attachment == nil { - return errors2.NewBusinessError(503, "附件设置为空") - } - - for _, att := range attachment.Attachments { - jobid, _ := att["jobid"].(string) - if jobid != p.JobID { - continue - } - - property, ok := att["property"].(map[string]interface{}) - if !ok { - continue - } - - if title, ok := property["title"].(string); ok { - p.Title = title - } - if jtoken, ok := att["jtoken"].(string); ok { - p.Jtoken = jtoken - } - if isJob, ok := att["job"].(bool); ok { - p.IsJob = isJob - } - break - } - - return nil -} - -// AttachmentsDetectionLive 从附件设置中检测并填充直播信息 -func (c *XuexitongCourse) AttachmentsDetectionLive(attachment *entity.AttachmentSetting, p *entity.PointLiveDto) error { - if attachment == nil { - return errors2.NewBusinessError(503, "附件设置为空") - } - - for _, att := range attachment.Attachments { - jobid, _ := att["jobid"].(string) - if jobid != p.JobID { - continue - } - - if authEnc, ok := att["authEnc"].(string); ok { - p.AuthEnc = authEnc - } - if liveDragEnc, ok := att["liveDragEnc"].(string); ok { - p.LiveDragEnc = liveDragEnc - } - if liveSetEnc, ok := att["liveSetEnc"].(string); ok { - p.LiveSetEnc = liveSetEnc - } - if otherInfo, ok := att["otherInfo"].(string); ok { - p.OtherInfo = otherInfo - } - if enc, ok := att["enc"].(string); ok { - p.Enc = enc - } - if liveSwDsEnc, ok := att["liveSwDsEnc"].(string); ok { - p.LiveSwDsEnc = liveSwDsEnc - } - if isJob, ok := att["job"].(bool); ok { - p.IsJob = isJob - } - if aid, ok := att["aid"].(float64); ok { - p.Aid = strconv.FormatInt(int64(aid), 10) - } - break - } - - return nil -} - -// AutoCompleteHyperlink 自动完成外链任务点 -func (c *XuexitongCourse) AutoCompleteHyperlink(ctx context.Context, course entity.XueXiTCourse, p *entity.PointHyperlinkDto) error { - if !p.IsJob { - logger.Logger.Info("外链不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - return nil - } - - logger.Logger.Info("开始刷外链", - zap.String("jobID", p.JobID), - zap.String("title", p.Title)) - - report, err := c.HyperlinkDtoCompleteReport(ctx, p) - if err != nil { - return fmt.Errorf("提交外链完成失败: %w", err) - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(report), &result); err == nil { - if status, ok := result["status"].(bool); ok && status { - logger.Logger.Info("外链刷课完成", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - c.onTaskComplete() // 任务点完成回调 - return nil - } - if msg, ok := result["msg"].(string); ok { - logger.Logger.Warn("外链无法正常学习", zap.String("msg", msg)) - } - } - - return nil -} - -// HyperlinkDtoCompleteReport 提交外链完成 -func (c *XuexitongCourse) HyperlinkDtoCompleteReport(ctx context.Context, p *entity.PointHyperlinkDto) (string, error) { - urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/job/hyperlink?jobid=%s&knowledgeid=%d&courseid=%s&clazzid=%s&jtoken=%s&checkMicroTopic=trueµTopicId=undefined&_dc=%d", - p.JobID, p.KnowledgeID, p.CourseID, p.ClassID, p.Jtoken, time.Now().UnixMilli()) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("web"), - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "提交外链完成失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// AutoWatchLive 自动观看直播任务点 -func (c *XuexitongCourse) AutoWatchLive(ctx context.Context, course entity.XueXiTCourse, p *entity.PointLiveDto) error { - if !p.IsJob { - logger.Logger.Info("直播不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - return nil - } - - // 获取直播信息 - if err := c.PullLiveInfo(ctx, p); err != nil { - logger.Logger.Warn("获取直播信息失败", zap.Error(err)) - } - - // 检查直播状态 - if p.LiveStatusCode == 0 { - logger.Logger.Info("直播还未开始,跳过", zap.String("title", p.Title)) - return nil - } - - // 如果已经完成90%以上,跳过 - if p.VideoCompletePercent >= 90 { - logger.Logger.Info("直播已完成", zap.String("title", p.Title), zap.Float64("percent", p.VideoCompletePercent)) - return nil - } - - logger.Logger.Info("开始刷直播", - zap.String("jobID", p.JobID), - zap.String("title", p.Title), - zap.Float64("currentPercent", p.VideoCompletePercent)) - - // 建立直播连接 - if _, err := c.LiveRelationReport(ctx, p); err != nil { - logger.Logger.Warn("建立直播连接失败", zap.Error(err)) - } - - // 循环提交观看进度直到完成 - for { - report, err := c.LiveSaveTimeReport(ctx, p) - if err != nil { - logger.Logger.Error("提交直播观看进度失败", zap.Error(err)) - return err - } - - // 更新直播信息 - if err := c.PullLiveInfo(ctx, p); err != nil { - logger.Logger.Warn("更新直播信息失败", zap.Error(err)) - } - - logger.Logger.Debug("直播观看中", - zap.String("title", p.Title), - zap.Float64("percent", p.VideoCompletePercent), - zap.String("report", report)) - - if p.VideoCompletePercent >= 90 { - logger.Logger.Info("直播刷课完成", zap.String("title", p.Title)) - c.onTaskComplete() // 任务点完成回调 - break - } - - time.Sleep(30 * time.Second) - } - - return nil -} - -// PullLiveInfo 获取直播信息 -func (c *XuexitongCourse) PullLiveInfo(ctx context.Context, p *entity.PointLiveDto) error { - urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid=%s&userid=%s&clazzid=%s&knowledgeid=%d&courseid=%s&jobid=%s&ut=s", - p.LiveId, p.UserId, p.ClassID, p.KnowledgeID, p.CourseID, p.JobID) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("web"), - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "获取直播信息失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return err - } - - var result map[string]interface{} - if err := json.Unmarshal([]byte(body), &result); err != nil { - return err - } - - if status, ok := result["status"].(bool); ok && status { - if temp, ok := result["temp"].(map[string]interface{}); ok { - if data, ok := temp["data"].(map[string]interface{}); ok { - if percentValue, ok := data["percentValue"].(float64); ok { - p.VideoCompletePercent = percentValue - } - if duration, ok := data["duration"].(float64); ok { - p.VideoDuration = int(duration) - } - if liveStatus, ok := data["liveStatus"].(float64); ok { - p.LiveStatusCode = int(liveStatus) - } - } - } - } - - return nil -} - -// LiveRelationReport 建立直播连接 -func (c *XuexitongCourse) LiveRelationReport(ctx context.Context, p *entity.PointLiveDto) (string, error) { - urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/mooc-ans/live/relation?courseid=%s&knowledgeid=%d&ut=s&jobid=%s&aid=%s", - p.CourseID, p.KnowledgeID, p.JobID, p.Aid) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("web"), - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "建立直播连接失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// LiveSaveTimeReport 提交直播观看进度 -func (c *XuexitongCourse) LiveSaveTimeReport(ctx context.Context, p *entity.PointLiveDto) (string, error) { - urlStr := fmt.Sprintf("https://zhibo.chaoxing.com/saveTimePc?streamName=%s&vdoid=%s&userId=%s&isStart=1&t=%d&courseId=%s", - p.StreamName, p.Vdoid, p.UserId, time.Now().UnixMilli(), p.CourseID) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("web"), - }).Get(urlStr) - if err != nil { - return err - } - if resp.StatusCode() != 200 { - return errors2.NewBusinessError(resp.StatusCode(), "提交直播观看进度失败") - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return "", err - } - - return body, nil -} - -// AutoCompleteWork 自动完成作业任务点 -func (c *XuexitongCourse) AutoCompleteWork(ctx context.Context, course entity.XueXiTCourse, p *entity.PointWorkDto) error { - if p.IsPassed { - logger.Logger.Info("作业已完成", zap.String("jobID", p.JobID), zap.String("title", p.Title)) - return nil - } - return nil - - logger.Logger.Info("开始处理作业", - zap.String("jobID", p.JobID), - zap.String("workID", p.WorkID), - zap.String("title", p.Title)) - - // 获取作业题目 - workDto, err := c.FetchWorkQuestions(ctx, p) - if err != nil { - return fmt.Errorf("获取作业题目失败: %w", err) - } - - if len(workDto.Questions) == 0 { - logger.Logger.Warn("作业没有题目", zap.String("title", p.Title)) - return nil - } - - logger.Logger.Info("获取作业题目成功", - zap.String("title", workDto.Title), - zap.Int("questionCount", len(workDto.Questions))) - - // 打印题目信息(答案需要外部提供,这里只是获取题目) - for i, q := range workDto.Questions { - typeStr := "未知" - switch q.Type { - case entity.QuestionTypeSingleChoice: - typeStr = "单选题" - case entity.QuestionTypeMultipleChoice: - typeStr = "多选题" - case entity.QuestionTypeFillBlank: - typeStr = "填空题" - case entity.QuestionTypeTrueFalse: - typeStr = "判断题" - case entity.QuestionTypeShortAnswer: - typeStr = "简答题" - } - logger.Logger.Debug("题目信息", - zap.Int("index", i+1), - zap.String("type", typeStr), - zap.String("text", q.Text)) - } - - // TODO: 这里需要外部提供答案(AI或题库) - // 目前只获取题目,不自动提交答案 - logger.Logger.Warn("作业需要外部提供答案(AI或题库),暂不自动提交", - zap.String("title", workDto.Title), - zap.Int("questionCount", len(workDto.Questions))) - - return nil -} - -// FetchWorkQuestions 获取作业题目 -func (c *XuexitongCourse) FetchWorkQuestions(ctx context.Context, p *entity.PointWorkDto) (*entity.WorkDto, error) { - params := url.Values{} - params.Add("courseid", p.CourseID) - // 处理 workid,可能需要加上 schoolid 前缀 - 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)) - params.Add("userid", c.userID) - params.Add("ut", "s") - params.Add("clazzId", p.ClassID) - params.Add("cpi", p.Cpi) - params.Add("ktoken", p.KToken) - params.Add("enc", p.Enc) - - var body string - err := retry.Do(func() error { - resp, err := c.client.R().SetHeaders(map[string]string{ - "User-Agent": GetUA("mobile"), - }).Get(ApiWorkFetch + "?" + params.Encode()) - if err != nil { - return err - } - body = resp.String() - return nil - }, retry.Attempts(3), retry.Delay(2*time.Second)) - - if err != nil { - return nil, err - } - - // 解析作业页面HTML,提取题目信息 - workDto, err := c.parseWorkHTML(body, p) - if err != nil { - return nil, err - } - - return workDto, nil -} - -// parseWorkHTML 解析作业HTML页面,提取题目信息 -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: c.userID, - } - - // 解析隐藏字段 - inputPattern := regexp.MustCompile(`]*name="([^"]*)"[^>]*value="([^"]*)"[^>]*>`) - matches := inputPattern.FindAllStringSubmatch(htmlContent, -1) - for _, match := range matches { - if len(match) >= 3 { - name := match[1] - value := match[2] - switch name { - case "title": - workDto.Title = value - case "workAnswerId": - workDto.WorkAnswerId = value - case "answerId": - workDto.AnswerId = value - case "totalQuestionNum": - workDto.TotalQuestionNum = value - case "fullScore": - workDto.FullScore = value - case "oldWorkId": - workDto.OldWorkId = value - case "oldSchoolId": - workDto.OldSchoolId = value - case "api": - workDto.Api = value - case "workRelationId": - workDto.WorkRelationId = value - case "enc_work": - workDto.EncWork = value - } - } - } - - // 解析题目 - // 题目通常在 class="Py-mian1" 的 div 中 - questionPattern := regexp.MustCompile(`