package publisher import ( "context" "encoding/base64" "fmt" "geo/internal/config" "log" "os" "path/filepath" "strings" "time" ) // ShipinhaoVideoPublisher 视频号视频发布器 type ShipinhaoVideoPublisher struct { *BasePublisher } // NewShipinhaoVideoPublisher 创建视频号发布器 func NewShipinhaoVideoPublisher(ctx context.Context, task *TaskParams, config *config.Config, logger *log.Logger) PublisherInerface { return &ShipinhaoVideoPublisher{ BasePublisher: NewBasePublisher(ctx, task, config, logger), } } // PublishNote 发布视频主流程 func (p *ShipinhaoVideoPublisher) PublishNote() (bool, string) { p.StartNote() // 1. 初始化浏览器 if err := p.SetupDriver(); err != nil { return false, fmt.Sprintf("浏览器启动失败: %v", err) } defer p.Page.Close() // 3. 加载 cookies 并检查登录状态 if err := p.LoadCookies(); err == nil { p.Page.Navigate(p.EditorURL) p.Sleep(3) if !p.CheckLoginStatus() { return false, "需要登录" } p.LogInfo("登录状态正常") } p.SaveCookies() // 4. 确保在正确的 iframe 中 p.ensureInEditorIframe() p.Sleep(2) // 6. 按顺序尝试各种上传方法 uploadSuccess := false var uploadMessage string methods := []struct { name string fn func() error }{ {"CDP拦截上传", p.uploadViaCDPIntercept}, //{"拖拽事件上传", p.uploadViaDragEvent}, //{"网络拦截上传", p.uploadViaNetworkIntercept}, //{"React事件上传", p.uploadViaReactEvent}, //{"文件输入框上传", p.uploadViaFileInput}, } for _, method := range methods { p.LogInfo(fmt.Sprintf("尝试 %s...", method.name)) err := method.fn() if err == nil { p.LogInfo(fmt.Sprintf("%s 成功", method.name)) uploadSuccess = true uploadMessage = fmt.Sprintf("%s成功", method.name) break } p.LogInfo(fmt.Sprintf("%s 失败: %v", method.name, err)) p.Sleep(1) } if !uploadSuccess { return false, fmt.Sprintf("所有上传方法均失败: %s", uploadMessage) } // 7. 等待上传完成 if success, msg := p.waitForUploadComplete(180); !success { p.LogInfo(fmt.Sprintf("上传等待可能未完成: %s", msg)) } // 8. 输入标题和描述 if success, msg := p.inputTitleAndDescription(); !success { return false, msg } // 9. 点击发布 if success, msg := p.clickPublish(); !success { return false, msg } // 10. 等待发布完成 p.Sleep(10) currentURL := p.GetCurrentURL() if strings.Contains(currentURL, "https://channels.weixin.qq.com/platform/post/list") { p.LogInfo("发布完成") return true, "发布成功" } return false, "发布失败" } // ensureInEditorIframe 确保在编辑器 iframe 中 func (p *ShipinhaoVideoPublisher) ensureInEditorIframe() { p.LogInfo("切换到编辑器 iframe") // 先切换到默认内容 p.Page.MustElement("body") // 确保在根页面 iframeSelectors := []string{ "iframe[name='content']", "wujie-app iframe", "iframe[src*='content']", } for _, selector := range iframeSelectors { exist, frameElement, err := p.Page.Has(selector) if err == nil && exist { frame, err := frameElement.Frame() if err == nil && frame != nil { p.Page = frame p.LogInfo(fmt.Sprintf("已切换到 iframe: %s", selector)) return } } } p.LogInfo("未找到 iframe,使用主页面") } // uploadViaFileInput 通过文件输入框上传(最基础的方法) func (p *ShipinhaoVideoPublisher) uploadViaFileInput(filePath string) error { p.LogInfo("使用文件输入框上传...") // 确保在正确的 iframe 中 p.ensureInEditorIframe() // 查找文件输入框(使用非阻塞方式) fileInputs, err := p.Page.Elements("input[type='file']") if err != nil { return fmt.Errorf("查找文件输入框失败: %v", err) } if len(fileInputs) == 0 { return fmt.Errorf("未找到文件输入框") } err = fileInputs[0].SetFiles([]string{filePath}) if err != nil { return fmt.Errorf("设置文件失败: %v", err) } p.LogInfo(fmt.Sprintf("文件已选择: %s", filepath.Base(filePath))) return nil } // uploadViaCDPIntercept 使用 CDP 拦截并注入文件上传 func (p *ShipinhaoVideoPublisher) uploadViaCDPIntercept() error { p.LogInfo("使用 CDP 协议拦截文件上传...") // 确保在正确的 iframe 中 p.ensureInEditorIframe() p.LogInfo("已切换到 iframe,开始查找文件输入框") p.Sleep(1) // 先在当前 iframe 中查找文件输入框 fileInputs, err := p.Page.Elements("input[type='file'][accept*='video']") if err != nil { p.LogInfo(fmt.Sprintf("查找文件输入框失败: %v", err)) } if len(fileInputs) > 0 { p.LogInfo(fmt.Sprintf("找到 %d 个文件输入框,尝试直接设置文件", len(fileInputs))) err = fileInputs[0].SetFiles([]string{p.SourcePath}) if err == nil { p.LogInfo("直接设置文件成功") return nil } p.LogInfo(fmt.Sprintf("直接设置文件失败: %v", err)) } //filePath := p.SourcePath //// 读取文件为 Base64 //fileData, err := os.ReadFile(filePath) //if err != nil { // return fmt.Errorf("读取文件失败: %v", err) //} //base64Data := base64.StdEncoding.EncodeToString(fileData) //fileName := filepath.Base(filePath) //// 如果直接设置失败,使用 JS 注入方式 //p.LogInfo("使用 JS 注入方式上传文件") // //// 注入 JS 代码模拟文件上传 //script := fmt.Sprintf(` // (function() { // // 创建 File 对象 // var byteCharacters = atob('%s'); // var byteNumbers = new Array(byteCharacters.length); // for (var i = 0; i < byteCharacters.length; i++) { // byteNumbers[i] = byteCharacters.charCodeAt(i); // } // var byteArray = new Uint8Array(byteNumbers); // var blob = new Blob([byteArray], {type: 'video/mp4'}); // var file = new File([blob], '%s', {type: 'video/mp4'}); // // // 查找隐藏的文件输入框 // var fileInput = document.querySelector('input[type="file"][accept*="video"]'); // if (!fileInput) { // fileInput = document.querySelector('input[type="file"]'); // } // // if (!fileInput) { // // 如果还是找不到,创建一个 // fileInput = document.createElement('input'); // fileInput.type = 'file'; // fileInput.accept = 'video/mp4,video/x-m4v,video/*'; // fileInput.multiple = true; // fileInput.style.display = 'none'; // document.body.appendChild(fileInput); // } // // // 临时显示文件输入框(如果需要) // var originalDisplay = fileInput.style.display; // fileInput.style.display = 'block'; // // // 使用 DataTransfer 设置文件 // var dataTransfer = new DataTransfer(); // dataTransfer.items.add(file); // fileInput.files = dataTransfer.files; // // // 恢复原始显示状态 // fileInput.style.display = originalDisplay; // // // 触发 change 事件 // var changeEvent = new Event('change', { bubbles: true }); // fileInput.dispatchEvent(changeEvent); // // // 触发 input 事件 // var inputEvent = new Event('input', { bubbles: true }); // fileInput.dispatchEvent(inputEvent); // // // 查找上传区域并触发点击 // var uploadArea = document.querySelector('.upload-wrap, .video-plugin-title-action, [class*="upload"]'); // if (uploadArea) { // uploadArea.click(); // } // // // 模拟拖拽事件 // var dropZones = document.querySelectorAll('.upload-wrap, [class*="upload"], [class*="drop"]'); // for (var i = 0; i < dropZones.length; i++) { // var zone = dropZones[i]; // if (zone.offsetParent !== null) { // var dragOverEvent = new DragEvent('dragover', { // bubbles: true, // cancelable: true, // dataTransfer: dataTransfer // }); // zone.dispatchEvent(dragOverEvent); // // var dropEvent = new DragEvent('drop', { // bubbles: true, // cancelable: true, // dataTransfer: dataTransfer // }); // zone.dispatchEvent(dropEvent); // break; // } // } // // return {success: true, fileName: '%s', hasFileInput: !!fileInput}; // })(); //`, base64Data, fileName, fileName) // //result, err := p.Page.Eval(script) //if err != nil { // return fmt.Errorf("CDP 注入失败: %v", err) //} // //p.LogInfo(fmt.Sprintf("CDP 注入完成,结果: %v", result)) return nil } // uploadViaDragEvent 通过模拟拖拽事件上传 func (p *ShipinhaoVideoPublisher) uploadViaDragEvent(filePath string) error { p.LogInfo("模拟拖拽事件上传...") // 读取文件为 Base64 fileData, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("读取文件失败: %v", err) } base64Data := base64.StdEncoding.EncodeToString(fileData) fileName := filepath.Base(filePath) // 确保在正确的 iframe 中 p.ensureInEditorIframe() p.Sleep(1) script := fmt.Sprintf(` (function() { // 创建模拟文件 var byteCharacters = atob('%s'); var byteNumbers = new Array(byteCharacters.length); for (var i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } var byteArray = new Uint8Array(byteNumbers); var blob = new Blob([byteArray], {type: 'video/mp4'}); var file = new File([blob], '%s', {type: 'video/mp4'}); var dataTransfer = new DataTransfer(); dataTransfer.items.add(file); // 查找上传区域 var dropZones = document.querySelectorAll('[class*="upload"], [class*="drop"], [class*="video"]'); var targetZone = null; for (var i = 0; i < dropZones.length; i++) { var zone = dropZones[i]; if (zone.offsetParent !== null && (zone.innerText.includes('上传') || zone.innerText.includes('时长') || zone.className.includes('upload'))) { targetZone = zone; break; } } if (!targetZone) { targetZone = document.body; } // 模拟拖拽事件 var dragOverEvent = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); targetZone.dispatchEvent(dragOverEvent); var dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); targetZone.dispatchEvent(dropEvent); return {success: true, message: '拖拽事件已触发'}; })(); `, base64Data, fileName) _, err = p.Page.Eval(script) if err != nil { return fmt.Errorf("拖拽事件上传失败: %v", err) } p.LogInfo("拖拽事件已触发") return nil } // uploadViaNetworkIntercept 通过拦截网络请求上传 func (p *ShipinhaoVideoPublisher) uploadViaNetworkIntercept(filePath string) error { p.LogInfo("尝试网络拦截上传...") // 确保在正确的 iframe 中 p.ensureInEditorIframe() // 点击上传区域 clickScript := ` var areas = document.querySelectorAll('[class*="upload"]'); for (var i = 0; i < areas.length; i++) { if (areas[i].offsetParent !== null) { areas[i].click(); return true; } } return false; ` _, err := p.Page.Eval(clickScript) if err != nil { p.LogInfo(fmt.Sprintf("点击上传区域失败: %v", err)) } p.Sleep(1) // 使用 CDP 设置文件输入 fileInputs, err := p.Page.Elements("input[type='file']") if err != nil || len(fileInputs) == 0 { return fmt.Errorf("未找到文件输入框") } err = fileInputs[0].SetFiles([]string{filePath}) if err != nil { return fmt.Errorf("设置文件失败: %v", err) } p.LogInfo("网络拦截上传完成") return nil } // uploadViaReactEvent 通过 React 内部事件上传 func (p *ShipinhaoVideoPublisher) uploadViaReactEvent(filePath string) error { p.LogInfo("尝试 React 事件上传...") // 读取文件为 Base64 fileData, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("读取文件失败: %v", err) } base64Data := base64.StdEncoding.EncodeToString(fileData) fileName := filepath.Base(filePath) // 确保在正确的 iframe 中 p.ensureInEditorIframe() script := fmt.Sprintf(` (function() { // 查找所有 DOM 元素 var allElements = document.querySelectorAll('*'); var uploadComponent = null; for (var i = 0; i < allElements.length; i++) { var el = allElements[i]; // 检查是否有 React 内部属性 if (el._reactRootContainer || Object.keys(el).some(key => key.startsWith('__react'))) { // 查找包含上传相关文本的元素 if (el.innerText && (el.innerText.includes('上传') || el.innerText.includes('时长'))) { uploadComponent = el; break; } } } if (uploadComponent) { // 创建模拟文件 var byteCharacters = atob('%s'); var byteNumbers = new Array(byteCharacters.length); for (var i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } var byteArray = new Uint8Array(byteNumbers); var blob = new Blob([byteArray], {type: 'video/mp4'}); var file = new File([blob], '%s', {type: 'video/mp4'}); var dataTransfer = new DataTransfer(); dataTransfer.items.add(file); // 触发 change 事件 var fileInput = document.querySelector('input[type="file"]'); if (fileInput) { fileInput.files = dataTransfer.files; var event = new Event('change', {bubbles: true}); fileInput.dispatchEvent(event); } // 尝试触发 React 的 onChange var syntheticEvent = new Event('change', {bubbles: true}); syntheticEvent.target = {files: dataTransfer.files}; uploadComponent.dispatchEvent(syntheticEvent); return {success: true}; } return {success: false, message: '未找到 React 组件'}; })(); `, base64Data, fileName) result, err := p.Page.Eval(script) if err != nil { return fmt.Errorf("React 事件上传失败: %v", err) } // 检查结果 if result != nil { p.LogInfo(fmt.Sprintf("React 事件触发结果: %v", result)) } return nil } // waitForUploadComplete 等待上传完成 func (p *ShipinhaoVideoPublisher) waitForUploadComplete(timeout int) (bool, string) { p.LogInfo("等待视频上传完成...") startTime := time.Now() for time.Since(startTime).Seconds() < float64(timeout) { // 检查是否还存在上传区域特征 exists, _, err := p.Page.Has(".form-item.flex-start") if err == nil && exists { p.LogInfo("视频上传成功") p.Sleep(2) return true, "上传完成" } p.Sleep(2) } return false, "上传超时" } // inputTitleAndDescription 输入标题和描述 func (p *ShipinhaoVideoPublisher) inputTitleAndDescription() (bool, string) { // 构建完整内容: "标题 #标签1 #标签2 #标签3" fullContent := p.Title if len(p.Tags) > 0 { var tagParts []string for _, tag := range p.Tags { if tag != "" { tagParts = append(tagParts, "#"+tag) } } if len(tagParts) > 0 { fullContent = fmt.Sprintf("%s %s", fullContent, strings.Join(tagParts, " ")) } } p.LogInfo(fmt.Sprintf("目标内容: %s", fullContent)) // 确保在正确的 iframe 中 p.ensureInEditorIframe() p.Sleep(1) // 使用 JavaScript 直接设置编辑器内容 script := ` function setEditorContent(content) { // 定位编辑器元素 var editor = document.querySelector('.post-desc-box .input-editor, .input-editor, [contenteditable="true"]'); if (!editor) { console.error('Editor element not found'); return false; } // 聚焦编辑器 editor.focus(); // 清空现有内容 editor.innerText = ''; // 设置新内容 editor.innerText = content; // 触发所有可能的事件 var events = ['input', 'change', 'blur', 'focus', 'keyup', 'keydown']; events.forEach(function(eventType) { var event = new Event(eventType, { bubbles: true, cancelable: true }); editor.dispatchEvent(event); }); // 尝试触发 React 的合成事件 var reactKey = Object.keys(editor).find(function(key) { return key.startsWith('__reactEventHandlers'); }); if (reactKey && editor[reactKey] && editor[reactKey].onChange) { var syntheticEvent = { target: { value: content, innerText: content }, type: 'change' }; editor[reactKey].onChange(syntheticEvent); } console.log('Content set successfully, final value:', editor.innerText); return true; } return setEditorContent(arguments[0]); ` result, err := p.Page.Eval(script, fullContent) if err != nil { return false, fmt.Sprintf("设置内容失败: %v", err) } if result != nil { p.LogInfo("通过 JS 成功设置内容") p.Sleep(1) return true, "内容输入成功" } return false, "未找到编辑器元素" } // clickPublish 点击发布按钮 func (p *ShipinhaoVideoPublisher) clickPublish() (bool, string) { p.LogInfo("点击发布按钮...") // 滚动到底部 p.Page.Eval(`window.scrollTo(0, document.body.scrollHeight);`) p.Sleep(1) // 确保在正确的 iframe 中 p.ensureInEditorIframe() p.Sleep(1) // 方法1: 通过文本 "发表" 查找按钮 script := ` var buttons = document.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var btn = buttons[i]; var text = btn.innerText || btn.textContent || ''; if (text.trim() === '发表' && btn.offsetParent !== null) { btn.click(); return true; } } return false; ` result, err := p.Page.Eval(script) if err == nil && result != nil { p.LogInfo("已点击发表按钮") return true, "已点击发表" } // 方法2: 通过 CSS 选择器查找 publishSelectors := []string{ ".weui-desktop-btn.weui-desktop-btn_primary", "button.weui-desktop-btn_primary", ".weui-desktop-btn_wrp button", "button[class*='primary']", } for _, selector := range publishSelectors { btn, err := p.Page.Element(selector) if err == nil && btn != nil { visible, _ := btn.Visible() if visible { p.JSClick(btn) p.LogInfo(fmt.Sprintf("通过选择器 %s 点击发表按钮", selector)) return true, "已点击发表" } } } p.LogError("所有方法都未找到发表按钮") return false, "未找到发表按钮" }