geoGo/internal/publisher/sphsp.go

640 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, "未找到发表按钮"
}