diff --git a/internal/biz/public.go b/internal/biz/public.go index 410eca6..f472a86 100644 --- a/internal/biz/public.go +++ b/internal/biz/public.go @@ -46,7 +46,10 @@ func (b *PublishBiz) ValidateAccessToken(ctx context.Context, accessToken string And(builder.Eq{"status": 1}) tokenInfo := &model.Token{} err := b.tokenImpl.GetOneBySearchStruct(ctx, &cond, tokenInfo) - if err != nil || tokenInfo == nil { + if err != nil { + return nil, errcode.Forbidden("密钥无效或已禁用") + } + if tokenInfo.ID == 0 { return nil, errcode.Forbidden("密钥无效或已禁用") } return tokenInfo, nil diff --git a/internal/data/impl/publish.go b/internal/data/impl/publish.go index 2888659..9d551d1 100644 --- a/internal/data/impl/publish.go +++ b/internal/data/impl/publish.go @@ -201,8 +201,8 @@ func (p *PublishImpl) GetListWithUser(ctx context.Context, tokenID int32, page, p.create_time, p.msg `). - Joins("LEFT JOIN user u ON p.user_index = u.user_index"). - Joins("LEFT JOIN plat pl ON p.plat_index = pl.index"). + Joins("LEFT JOIN user u ON p.user_index COLLATE utf8mb4_unicode_ci = u.user_index"). + Joins("LEFT JOIN plat pl ON p.plat_index COLLATE utf8mb4_unicode_ci = pl.index"). Where("u.token_id = ?", tokenID) // 添加过滤条件 diff --git a/internal/manager/publish_manager.go b/internal/manager/publish_manager.go index 8f9cbf1..8a69e4f 100644 --- a/internal/manager/publish_manager.go +++ b/internal/manager/publish_manager.go @@ -450,7 +450,7 @@ func (pm *PublishManager) extractTaskParams(publishData *entitys.PublishTaskDeta func (pm *PublishManager) downloadAndPrepareFiles(requestId string, params *fileUrl, taskLogger *log.Logger, publishClass *publisher.PublisherValue) (docPath, imgPath string, err error) { // 下载文档 taskLogger.Printf("[任务 %s] 开始下载文档...", requestId) - docPath, err = pkg.DownloadFile(params.url, pm.Conf.Sys.DocsDir, requestId+".docx") + docPath, err = pkg.DownloadFile(params.url, pm.Conf.Sys.DocsDir, requestId) if err != nil { return "", "", fmt.Errorf("下载文档失败: %v", err) } diff --git a/internal/publisher/baijiahao.go b/internal/publisher/baijiahao.go index 901473c..af9f24b 100644 --- a/internal/publisher/baijiahao.go +++ b/internal/publisher/baijiahao.go @@ -104,13 +104,9 @@ func (p *BaijiahaoPublisher) PublishNote() (bool, string) { } driverCreated = true - p.Page.MustNavigate(p.EditorURL) - p.Sleep(3) - p.WaitForPageReady(5) - if p.LoadCookies() == nil { - p.RefreshPage() - p.Sleep(3) + p.Page.MustNavigate(p.EditorURL) + p.WaitForPageReady(5) if p.CheckLoginStatus() { return p.doPublish() } diff --git a/internal/publisher/base.go b/internal/publisher/base.go index 9c3bbd9..7dd035e 100644 --- a/internal/publisher/base.go +++ b/internal/publisher/base.go @@ -127,6 +127,9 @@ func (b *BasePublisher) SetupDriver() error { os.MkdirAll(userDataDir, 0755) l.UserDataDir(userDataDir) + // 关键优化:不重新使用已有的数据目录时不要清除 + l.Set("profile-directory", "Default") + // 设置 Leakless 模式(解决 Windows 上的问题) l.Leakless(false) @@ -294,8 +297,12 @@ func (b *BasePublisher) WaitLogin() (bool, string) { return false, "需要实现" } -func (b *BasePublisher) CheckLoginStatus() bool { - return false +func (p *BasePublisher) CheckLoginStatus() bool { + currentURL := p.GetCurrentURL() + if strings.Contains(currentURL, p.LoginURL) { + return false + } + return true } func (b *BasePublisher) CheckLogin() (bool, string) { @@ -354,3 +361,21 @@ func (b *BasePublisher) StartNote() { b.LogInfo(fmt.Sprintf("标签: %v", b.Tags)) b.LogInfo(strings.Repeat("=", 50)) } + +// InitPage 初始化页面 +func (p *BasePublisher) InitPage() error { + + // 尝试加载cookies并检查登录状态 + if err := p.LoadCookies(); err == nil { + p.Page.MustNavigate(p.EditorURL) + p.WaitForPageReady(5) + p.Sleep(2) + } + // 统一检查登录状态 + if !p.CheckLoginStatus() { + p.LogInfo("未登录或登录已过期,需要重新登录") + return fmt.Errorf("需要登录") + } + p.SaveCookies() + return nil +} diff --git a/internal/publisher/csdn.go b/internal/publisher/csdn.go new file mode 100644 index 0000000..d537973 --- /dev/null +++ b/internal/publisher/csdn.go @@ -0,0 +1,220 @@ +package publisher + +import ( + "fmt" + "geo/internal/config" + "log" + "strings" + "time" + + "github.com/go-rod/rod" +) + +type CSDNPublisher struct { + *BasePublisher +} + +func NewCSDNPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { + return &CSDNPublisher{NewBasePublisher(task, cfg, logger)} +} + +func (p *CSDNPublisher) CheckLogin() (bool, string) { + p.LogInfo("检查登录状态...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.EditorURL) + p.Sleep(2) + p.WaitForPageReady(3) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "已登录" + } + return false, "未登录" +} + +func (p *CSDNPublisher) WaitLogin() (bool, string) { + p.LogInfo("开始等待登录...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.EditorURL) + p.Sleep(3) + + if p.CheckLoginStatus() { + p.SaveCookies() + p.LogInfo("已有登录状态") + return true, "already_logged_in" + } + + startTime := time.Now() + timeout := 120 + for time.Since(startTime) < time.Duration(timeout)*time.Second { + time.Sleep(1 * time.Second) + if p.CheckLoginStatus() { + p.SaveCookies() + p.LogInfo("登录成功") + return true, "login_success" + } + p.SleepMs(1000) + } + + return false, "登录超时,请检查网络或账号状态" +} + +func (p *CSDNPublisher) inputTitle() error { + p.LogInfo("输入文章标题...") + + titleInput, err := p.WaitForElementVisible("#txtTitle", 5) + if err != nil { + return fmt.Errorf("未找到标题输入框: %v", err) + } + + p.LogInfo("找到标题输入框") + + if err := p.ClearInput(titleInput); err != nil { + titleInput.Input("") + } + p.SleepMs(200) + + if err := p.SetInputValue(titleInput, p.Title); err != nil { + titleInput.Input(p.Title) + } + + p.LogInfo(fmt.Sprintf("标题已输入: %s", p.Title)) + + p.SleepMs(300) + return nil +} + +func (p *CSDNPublisher) inputContent(titleInput *rod.Element) error { + p.LogInfo("输入文章内容...") + + p.SleepMs(1000) + return nil +} + +func (p *CSDNPublisher) clickPublish() error { + p.LogInfo("点击发布按钮...") + + btnBox, err := p.WaitForElementVisible("div.btn-box", 3) + if err != nil { + return fmt.Errorf("未找到发布按钮区域: %v", err) + } + + publishBtn, err := btnBox.ElementX(".//button[contains(@class, 'btn-outline-danger') and .//span[text()='发布博客']]") + if err != nil { + publishBtn, err = p.Page.ElementX("//button[contains(@class, 'btn-outline-danger') and .//span[text()='发布博客']]") + if err != nil { + return fmt.Errorf("未找到发布按钮: %v", err) + } + } + + if err := publishBtn.ScrollIntoView(); err != nil { + p.LogInfo(fmt.Sprintf("滚动到发布按钮失败: %v", err)) + } + p.SleepMs(300) + + if err := p.JSClick(publishBtn); err != nil { + return fmt.Errorf("点击发布按钮失败: %v", err) + } + + p.LogInfo("已点击发布按钮") + p.SleepMs(2000) + return nil +} + +func (p *CSDNPublisher) waitForPublishResult() (bool, string) { + p.LogInfo("等待发布结果...") + + for attempt := 0; attempt < 10; attempt++ { + currentURL := p.GetCurrentURL() + p.LogInfo(fmt.Sprintf("第 %d 次检查 - URL: %s", attempt+1, currentURL)) + + if strings.Contains(currentURL, "success") { + p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) + return true, "发布成功" + } + + errorSelectors := []string{ + "[class*='el_mcm-message--error'] .el_mcm-message__content", + "[class*='el_mcm-message--error']", + "[class*='message--error']", + ".error", + ".alert-error", + } + + for _, selector := range errorSelectors { + errorElements, _ := p.Page.Elements(selector) + for _, elem := range errorElements { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if text != "" { + p.LogError(fmt.Sprintf("发布失败: %s", text)) + return false, fmt.Sprintf("发布失败: %s", text) + } + } + } + } + + // 截图保存用于调试 + if attempt == 1 || attempt == 9 { + screenshotPath := fmt.Sprintf("debug_%s_%d.png", p.RequestID, attempt) + p.Screenshot(screenshotPath) + p.LogInfo(fmt.Sprintf("已保存截图: %s", screenshotPath)) + } + + p.SleepMs(1000) + } + + return false, "发布结果未知" +} + +func (p *CSDNPublisher) PublishNote() (bool, string) { + p.StartNote() + + // 初始化浏览器 + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + // 执行发布流程 + steps := []struct { + name string + fn func() error + }{ + {"初始化", p.InitPage}, + {"保存cookie", p.SaveCookies}, + {"输入标题", p.inputTitle}, + {"输入内容", func() error { + titleInput, _ := p.WaitForElementVisible("#txtTitle", 5) + return p.inputContent(titleInput) + }}, + {"点击发布", p.clickPublish}, + } + + for _, step := range steps { + if err := step.fn(); err != nil { + p.LogStep(step.name, false, err.Error()) + return false, fmt.Sprintf("%s失败: %v", step.name, err) + } + p.LogStep(step.name, true, "") + p.SleepMs(500) + } + + // 等待发布结果 + return p.waitForPublishResult() +} + +func (p *CSDNPublisher) LogWarning(message string) { + p.Logger.Printf("⚠️ %s", message) +} diff --git a/internal/publisher/dysp.go b/internal/publisher/dysp.go new file mode 100644 index 0000000..b816db1 --- /dev/null +++ b/internal/publisher/dysp.go @@ -0,0 +1,427 @@ +package publisher + +import ( + "fmt" + "geo/internal/config" + "log" + "os" + "strings" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +type DouyinSpPublisher struct { + *BasePublisher +} + +func NewDouyinSpPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { + return &DouyinSpPublisher{NewBasePublisher(task, cfg, logger)} +} + +func (p *DouyinSpPublisher) CheckLogin() (bool, string) { + p.LogInfo("检查登录状态...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.EditorURL) + p.Sleep(3) + p.WaitForPageReady(5) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "已登录" + } + return false, "未登录" +} + +func (p *DouyinSpPublisher) CheckLoginStatus() bool { + currentURL := p.GetCurrentURL() + if strings.Contains(currentURL, p.LoginedURL) { + return true + } + if strings.Contains(currentURL, p.EditorURL) { + return true + } + return false +} + +func (p *DouyinSpPublisher) WaitLogin() (bool, string) { + p.LogInfo("开始等待登录...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.LoginURL) + p.Sleep(2) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "already_logged_in" + } + + startTime := time.Now() + timeout := 120 + for time.Since(startTime) < time.Duration(timeout)*time.Second { + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "login_success" + } + p.SleepMs(1000) + } + + return false, "登录超时,请检查网络或账号状态" +} + +func (p *DouyinSpPublisher) waitForEditorReady(timeout int) bool { + p.LogInfo("等待编辑器加载...") + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + uploadArea, err := p.Page.Element(".container-drag-icon") + if err == nil && uploadArea != nil { + p.LogInfo("编辑器加载完成") + return true + } + p.SleepMs(1000) + } + p.LogInfo("编辑器加载超时") + return false +} + +func (p *DouyinSpPublisher) uploadVideo() error { + if p.SourcePath == "" { + return fmt.Errorf("视频不存在") + } + if _, err := os.Stat(p.SourcePath); os.IsNotExist(err) { + return fmt.Errorf("视频不存在") + } + + p.LogInfo(fmt.Sprintf("开始上传视频: %s", p.SourcePath)) + + fileInputSelectors := []string{ + "input[type='file'][accept*='video']", + ".container-drag-VAfIfu input[type='file']", + "input[accept*='video']", + } + + var fileInput *rod.Element + for _, selector := range fileInputSelectors { + fileInput, _ = p.Page.Element(selector) + if fileInput != nil { + p.LogInfo(fmt.Sprintf("找到文件上传输入框: %s", selector)) + break + } + } + + if fileInput == nil { + uploadArea, err := p.WaitForElementVisible(".container-drag-VAfIfu", 5) + if err == nil && uploadArea != nil { + p.LogInfo("点击上传区域") + p.JSClick(uploadArea) + p.SleepMs(1000) + + for _, selector := range fileInputSelectors { + fileInput, _ = p.Page.Element(selector) + if fileInput != nil { + break + } + } + } + } + + if fileInput == nil { + return fmt.Errorf("未找到文件上传输入框") + } + + if err := fileInput.SetFiles([]string{p.SourcePath}); err != nil { + return fmt.Errorf("上传视频失败: %v", err) + } + p.LogInfo(fmt.Sprintf("视频文件已选择: %s", p.SourcePath)) + + return p.waitForUploadComplete() +} + +func (p *DouyinSpPublisher) waitForUploadComplete() error { + p.LogInfo("等待视频上传完成...") + + for i := 0; i < 300; i++ { + successElements, _ := p.Page.Elements(".upload-success, [class*='success']") + if len(successElements) > 0 { + p.LogInfo("视频上传成功") + p.SleepMs(2000) + return nil + } + + errorElements, _ := p.Page.Elements(".upload-error, [class*='error']") + for _, elem := range errorElements { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if text != "" { + return fmt.Errorf("上传失败: %s", text) + } + } + } + + p.SleepMs(1000) + } + + return fmt.Errorf("上传超时") +} + +func (p *DouyinSpPublisher) inputTitle() error { + p.LogInfo("输入视频标题...") + + titleSelectors := []string{ + "textarea[placeholder*='标题']", + "input[placeholder*='标题']", + ".container-sGoJ9f input", + ".semiInput-EyEyPL input", + } + + for _, selector := range titleSelectors { + titleInput, err := p.WaitForElementVisible(selector, 5) + if err == nil && titleInput != nil { + p.LogInfo(fmt.Sprintf("找到标题输入框: %s", selector)) + + p.ClearInput(titleInput) + p.SleepMs(300) + titleInput.Input("") + p.SleepMs(300) + titleInput.Input(p.Title) + + p.LogInfo(fmt.Sprintf("标题已输入: %s", p.Title)) + p.SleepMs(500) + return nil + } + } + + return fmt.Errorf("未找到标题输入框") +} + +func (p *DouyinSpPublisher) inputDescription() error { + p.LogInfo("输入视频描述...") + + descSelectors := []string{ + ".editor-kit-container", + ".ProseMirror", + "[contenteditable='true']", + } + + for _, selector := range descSelectors { + descInput, err := p.WaitForElementVisible(selector, 5) + if err == nil && descInput != nil { + p.LogInfo(fmt.Sprintf("找到描述输入框: %s", selector)) + + fullDescription := "" + for _, tag := range p.Tags { + fullDescription += fmt.Sprintf("#%s ", tag) + } + fullDescription = strings.TrimSpace(fullDescription) + + p.SetContentEditable(descInput, fullDescription) + p.SleepMs(3000) + return nil + } + } + + return fmt.Errorf("未找到描述输入框") +} + +func (p *DouyinSpPublisher) clickPublish() error { + p.LogInfo("点击发布按钮...") + + p.Page.Eval(`() => window.scrollTo(0, document.body.scrollHeight)`) + p.SleepMs(2000) + + var publishBtn *rod.Element + + popoverSpan, err := p.WaitForElementVisible("#popover-tip-container", 5) + if err == nil && popoverSpan != nil { + publishBtn, _ = popoverSpan.Element("button") + if publishBtn != nil { + text, _ := publishBtn.Text() + if text == "发布" { + p.LogInfo("通过 popover-tip-container 找到发布按钮") + } + } + } + + if publishBtn == nil { + publishSelectors := []string{ + "button:contains('发布')", + "button.primary-cECiOJ", + "button.primary_button", + "button[class*='primary']", + } + for _, selector := range publishSelectors { + publishBtn, _ = p.WaitForElementClickable(selector, 3) + if publishBtn != nil { + p.LogInfo(fmt.Sprintf("找到发布按钮: %s", selector)) + break + } + } + } + + if publishBtn == nil { + p.Page.Eval(`() => window.scrollTo(0, document.body.scrollHeight)`) + p.SleepMs(1000) + allButtons, _ := p.Page.Elements("button") + for _, btn := range allButtons { + text, _ := btn.Text() + if text == "发布" { + publishBtn = btn + p.LogInfo("通过遍历按钮找到发布按钮") + break + } + } + } + + if publishBtn == nil { + return fmt.Errorf("未找到发布按钮") + } + + p.SleepMs(500) + + if err := publishBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := p.JSClick(publishBtn); err != nil { + return fmt.Errorf("点击发布按钮失败: %v", err) + } + } + + p.LogInfo("已点击发布按钮") + p.SleepMs(3000) + + confirmSelectors := []string{ + ".semi-modal .semi-button-primary", + ".confirm-btn-JwJNCk", + "button:contains('确认')", + "button:contains('确定')", + } + for _, selector := range confirmSelectors { + confirmBtn, _ := p.WaitForElementClickable(selector, 2) + if confirmBtn != nil { + p.JSClick(confirmBtn) + p.LogInfo("已确认发布") + p.SleepMs(2000) + break + } + } + + return nil +} + +func (p *DouyinSpPublisher) waitForPublishResult(timeout int) (bool, string) { + p.LogInfo("等待发布结果...") + + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + currentURL := p.GetCurrentURL() + + successKeywords := []string{"content/manage", "work-management", "success"} + for _, keyword := range successKeywords { + if strings.Contains(currentURL, keyword) { + p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) + return true, "发布成功" + } + } + + successSelectors := []string{".semi-toast-content", ".toast-success", "[class*='success']"} + for _, selector := range successSelectors { + msgs, _ := p.Page.Elements(selector) + for _, elem := range msgs { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if strings.Contains(text, "成功") || strings.Contains(text, "已发布") { + p.LogInfo(fmt.Sprintf("发布成功: %s", text)) + return true, text + } + } + } + } + + errorSelectors := []string{".semi-toast-content", ".toast-error", "[class*='error']"} + for _, selector := range errorSelectors { + msgs, _ := p.Page.Elements(selector) + for _, elem := range msgs { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if strings.Contains(text, "失败") || strings.Contains(strings.ToLower(text), "error") { + p.LogError(fmt.Sprintf("发布失败: %s", text)) + return false, text + } + } + } + } + + p.SleepMs(1000) + } + + return false, "发布结果未知(超时)" +} + +// InitPage 初始化页面 +func (p *DouyinSpPublisher) InitPage() error { + + // 尝试加载cookies并检查登录状态 + if err := p.LoadCookies(); err == nil { + p.Page.MustNavigate(p.EditorURL) + p.WaitForPageReady(5) + p.Sleep(2) + } + // 统一检查登录状态 + if !p.CheckLoginStatus() { + p.LogInfo("未登录或登录已过期,需要重新登录") + return fmt.Errorf("需要登录") + } + p.SaveCookies() + return nil +} + +func (p *DouyinSpPublisher) PublishNote() (bool, string) { + p.StartNote() + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + steps := []struct { + name string + fn func() error + }{ + {"初始化页面", p.InitPage}, + {"上传视频", p.uploadVideo}, + {"输入标题", p.inputTitle}, + {"输入描述", p.inputDescription}, + {"点击发布", p.clickPublish}, + } + + for _, step := range steps { + if err := step.fn(); err != nil { + p.LogStep(step.name, false, err.Error()) + return false, fmt.Sprintf("%s失败: %v", step.name, err) + } + p.LogStep(step.name, true, "") + } + + success, message := p.waitForPublishResult(120) + if success { + p.LogInfo(fmt.Sprintf("🎉 发布成功!%s", message)) + return true, message + } + p.LogError(fmt.Sprintf("发布失败: %s", message)) + return false, message +} + +func (p *DouyinSpPublisher) LogWarning(message string) { + p.Logger.Printf("⚠️ %s", message) +} diff --git a/internal/publisher/interface.go b/internal/publisher/interface.go index d33fcaf..de049e0 100644 --- a/internal/publisher/interface.go +++ b/internal/publisher/interface.go @@ -49,4 +49,68 @@ var PublisherMap = map[string]*PublisherValue{ Type: 1, WordContainImg: true, }, + "wyh": { + Name: "网易号", + InitMethod: NewWangyiPublisher, + ContentFormat: "text", + ImgNeed: 1, + Type: 1, + WordContainImg: true, + }, + "shh": { + Name: "搜狐号", + InitMethod: NewSohuPublisher, + ContentFormat: "text", + ImgNeed: 1, + Type: 1, + WordContainImg: false, + }, + "zh": { + Name: "知乎", + InitMethod: NewZhihuPublisher, + ContentFormat: "text", + ImgNeed: 1, + Type: 1, + WordContainImg: true, + }, + "js": { + Name: "简书", + InitMethod: NewJianshuPublisher, + ContentFormat: "markdown", + ImgNeed: 1, + Type: 1, + WordContainImg: false, + }, + "dysp": { + Name: "抖音视频", + InitMethod: NewDouyinSpPublisher, + ContentFormat: "video", + ImgNeed: 1, + Type: 2, + WordContainImg: false, + }, + "xhssp": { + Name: "小红书视频", + InitMethod: NewXiaohongshuVideoPublisher, + ContentFormat: "video", + ImgNeed: 1, + Type: 2, + WordContainImg: false, + }, + "sphsp": { + Name: "视频号视频", + InitMethod: NewShipinhaoVideoPublisher, + ContentFormat: "video", + ImgNeed: 1, + Type: 2, + WordContainImg: false, + }, + "csdn": { + Name: "CSDN", + InitMethod: NewCSDNPublisher, + ContentFormat: "markdown", + ImgNeed: 1, + Type: 2, + WordContainImg: false, + }, } diff --git a/internal/publisher/js.go b/internal/publisher/js.go new file mode 100644 index 0000000..f8cf8f8 --- /dev/null +++ b/internal/publisher/js.go @@ -0,0 +1,335 @@ +package publisher + +import ( + "fmt" + "geo/internal/config" + "log" + "strings" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +type JianshuPublisher struct { + *BasePublisher +} + +func NewJianshuPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { + return &JianshuPublisher{NewBasePublisher(task, cfg, logger)} +} + +func (p *JianshuPublisher) CheckLogin() (bool, string) { + p.LogInfo("检查登录状态...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.EditorURL) + p.Sleep(3) + p.WaitForPageReady(5) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "已登录" + } + return false, "未登录" +} + +func (p *JianshuPublisher) CheckLoginStatus() bool { + currentURL := p.GetCurrentURL() + if strings.Contains(currentURL, p.LoginURL) { + return false + } + return true +} + +func (p *JianshuPublisher) WaitLogin() (bool, string) { + p.LogInfo("开始等待登录...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.EditorURL) + p.Sleep(3) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "already_logged_in" + } + + startTime := time.Now() + timeout := 240 + for time.Since(startTime) < time.Duration(timeout)*time.Second { + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "login_success" + } + p.SleepMs(1000) + } + + return false, "登录超时,请检查网络或账号状态" +} + +func (p *JianshuPublisher) waitForEditorReady() error { + p.LogInfo("等待编辑器加载...") + + plusIcon, err := p.WaitForElementVisible(".fa-plus-circle", 10) + if err == nil && plusIcon != nil { + p.JSClick(plusIcon) + p.LogInfo("已点击新建文章按钮") + p.Sleep(2) + } else { + return fmt.Errorf("未找到新建文章按钮") + } + + startTime := time.Now() + for time.Since(startTime) < time.Duration(60)*time.Second { + editor, _ := p.WaitForElementVisible("#arthur-editor", 2) + if editor != nil { + p.LogInfo("编辑器加载完成") + return nil + } + p.SleepMs(1000) + } + + p.LogInfo("编辑器加载超时") + return nil +} + +func (p *JianshuPublisher) inputTitle() error { + p.LogInfo("输入文章标题...") + + today := time.Now().Format("2006-01-02") + selector := fmt.Sprintf("input[value='%s']", today) + titleInput, err := p.WaitForElementVisible(selector, 5) + if err != nil { + return fmt.Errorf("未找到标题输入框") + } + p.LogInfo("找到标题输入框") + p.ClearInput(titleInput) + p.SleepMs(300) + titleInput.Input("") + p.SleepMs(300) + titleInput.Input(p.Title) + + return nil +} + +func (p *JianshuPublisher) inputContent() error { + p.LogInfo("输入文章正文...") + + if p.Content == "" { + p.LogInfo("内容为空") + return fmt.Errorf("内容为空") + } + + textArea, err := p.WaitForElementVisible("#arthur-editor", 10) + if err != nil { + return fmt.Errorf("未找到文本输入框: %v", err) + } + p.LogInfo("找到文本输入框") + + p.JSClick(textArea) + p.SleepMs(500) + + textArea.Input(p.Content) + p.SleepMs(2000) + p.LogInfo(fmt.Sprintf("内容已输入,长度: %d", len(p.Content))) + return nil +} + +func (p *JianshuPublisher) clickPublish() error { + p.LogInfo("点击发布按钮...") + + publishSelectors := []string{ + "a[data-action='publicize']", + ".publish-btn", + ".submit-btn", + "button:contains('发布')", + ".btn-publish", + "[class*='publish'] button", + "a:contains('发布文章')", + } + + var publishBtn *rod.Element + for _, selector := range publishSelectors { + publishBtn, _ = p.WaitForElementClickable(selector, 5) + if publishBtn != nil { + visible, _ := publishBtn.Visible() + if visible { + p.LogInfo(fmt.Sprintf("找到发布按钮: %s", selector)) + break + } + } + } + + if publishBtn == nil { + links, _ := p.Page.Elements("a") + for _, link := range links { + text, _ := link.Text() + if strings.Contains(text, "发布文章") || strings.Contains(text, "发布") { + publishBtn = link + p.LogInfo("通过遍历链接找到发布按钮") + break + } + } + } + + if publishBtn == nil { + return fmt.Errorf("未找到发布按钮") + } + + if err := publishBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := p.JSClick(publishBtn); err != nil { + return fmt.Errorf("点击发布按钮失败: %v", err) + } + } + + p.LogInfo("已点击发布按钮") + p.Sleep(3) + return nil +} + +func (p *JianshuPublisher) handlePublishDialog() error { + p.LogInfo("处理发布弹窗...") + + dialogSelectors := []string{ + ".ant-modal", + ".el-dialog", + ".publish-dialog", + "[class*='dialog']", + "[role='dialog']", + } + + var dialog *rod.Element + for _, selector := range dialogSelectors { + dialog, _ = p.WaitForElementVisible(selector, 5) + if dialog != nil { + p.LogInfo(fmt.Sprintf("找到发布弹窗: %s", selector)) + break + } + } + + if dialog == nil { + p.LogInfo("未发现发布弹窗") + return nil + } + + confirmSelectors := []string{ + "button:contains('确认发布')", + "button:contains('确定')", + "button:contains('发布')", + ".confirm-btn", + ".ok-btn", + } + + for _, selector := range confirmSelectors { + confirmBtn, err := p.WaitForElementClickable(selector, 3) + if err == nil && confirmBtn != nil { + p.JSClick(confirmBtn) + p.LogInfo("已确认发布") + p.Sleep(2) + return nil + } + } + + closeSelectors := []string{ + ".ant-modal-close", + ".el-dialog__close", + ".close-btn", + } + + for _, selector := range closeSelectors { + closeBtn, err := p.WaitForElementClickable(selector, 2) + if err == nil && closeBtn != nil { + p.JSClick(closeBtn) + p.LogInfo("关闭发布弹窗") + p.SleepMs(1000) + break + } + } + + return nil +} + +func (p *JianshuPublisher) waitForPublishResult(timeout int) (bool, string) { + p.LogInfo("等待发布结果...") + + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + currentURL := p.GetCurrentURL() + + successKeywords := []string{"notes", "articles", "success", "published"} + for _, keyword := range successKeywords { + if strings.Contains(strings.ToLower(currentURL), keyword) { + p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) + return true, "发布成功" + } + } + + successSelectors := []string{ + ".ant-message-success", + ".el-message--success", + ".toast-success", + "[class*='success']", + } + for _, selector := range successSelectors { + elems, _ := p.Page.Elements(selector) + for _, elem := range elems { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if strings.Contains(text, "成功") || strings.Contains(strings.ToLower(text), "success") || strings.Contains(text, "已发布") { + p.LogInfo(fmt.Sprintf("发布成功: %s", text)) + return true, text + } + } + } + } + + p.SleepMs(1000) + } + + return false, "发布结果未知(超时)" +} + +func (p *JianshuPublisher) PublishNote() (bool, string) { + p.StartNote() + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + steps := []struct { + name string + fn func() error + }{ + {"初始化页面", p.InitPage}, + {"创建编辑器", p.waitForEditorReady}, + {"输入标题", p.inputTitle}, + {"导入内容", p.inputContent}, + {"点击发布", p.clickPublish}, + } + + for _, step := range steps { + if err := step.fn(); err != nil { + p.LogStep(step.name, false, err.Error()) + return false, fmt.Sprintf("%s失败: %v", step.name, err) + } + p.LogStep(step.name, true, "") + } + + success, message := p.waitForPublishResult(60) + if success { + p.LogInfo(fmt.Sprintf("🎉 发布成功!%s", message)) + return true, message + } + p.LogError(fmt.Sprintf("发布失败: %s", message)) + return false, message +} diff --git a/internal/publisher/shh.go b/internal/publisher/shh.go new file mode 100644 index 0000000..d1a6457 --- /dev/null +++ b/internal/publisher/shh.go @@ -0,0 +1,522 @@ +package publisher + +import ( + "fmt" + "geo/internal/config" + "log" + "strings" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +type SohuPublisher struct { + *BasePublisher +} + +func NewSohuPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { + return &SohuPublisher{NewBasePublisher(task, cfg, logger)} +} + +func (p *SohuPublisher) CheckLogin() (bool, string) { + p.LogInfo("检查登录状态...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.EditorURL) + p.Sleep(3) + p.WaitForPageReady(5) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "已登录" + } + return false, "未登录" +} + +func (p *SohuPublisher) CheckLoginStatus() bool { + currentURL := p.GetCurrentURL() + if strings.Contains(currentURL, p.LoginURL) { + return false + } + if strings.Contains(currentURL, "clientAuth") { + return false + } + return true +} + +func (p *SohuPublisher) WaitLogin() (bool, string) { + p.LogInfo("开始等待登录...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + if p.LoginedURL != "" { + p.Page.MustNavigate(p.LoginedURL) + p.Sleep(3) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "already_logged_in" + } + } + + p.Page.MustNavigate(p.LoginURL) + p.LogInfo("请扫描二维码登录...") + + for i := 0; i < 120; i++ { + p.SleepMs(1000) + if p.CheckLoginStatus() { + p.SaveCookies() + p.LogInfo("登录成功") + return true, "login_success" + } + } + + return false, "登录超时,请检查网络或账号状态" +} + +func (p *SohuPublisher) waitForEditorReady(timeout int) bool { + p.LogInfo("等待编辑器加载...") + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + titleSelectors := []string{ + ".publish-title input", + "input[placeholder*='标题']", + ".article-title input", + "[class*='title'] input", + } + for _, selector := range titleSelectors { + el, err := p.Page.Element(selector) + if err == nil && el != nil { + visible, _ := el.Visible() + if visible { + p.LogInfo("编辑器加载完成") + return true + } + } + } + p.SleepMs(1000) + } + p.LogInfo("编辑器加载超时") + return false +} + +func (p *SohuPublisher) inputTitle() error { + p.LogInfo("输入文章标题...") + + titleSelectors := []string{ + ".publish-title input", + "input[placeholder*='请输入标题']", + "input[placeholder*='标题']", + ".article-title-input input", + ".title-input input", + } + + for _, selector := range titleSelectors { + titleInput, err := p.WaitForElementVisible(selector, 5) + if err == nil && titleInput != nil { + p.LogInfo(fmt.Sprintf("找到标题输入框: %s", selector)) + + if err := p.ClearInput(titleInput); err != nil { + titleInput.Input("") + } + p.SleepMs(300) + titleInput.Input(p.Title) + p.SleepMs(300) + + p.LogInfo(fmt.Sprintf("标题已输入: %s", p.Title)) + + _, err = titleInput.Evaluate(&rod.EvalOptions{ + JS: `(el) => { + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + el.dispatchEvent(new Event('blur', {bubbles: true})); + }`, + }) + p.SleepMs(500) + return nil + } + } + + return fmt.Errorf("未找到标题输入框") +} + +func (p *SohuPublisher) inputContent() error { + p.LogInfo("输入文章正文...") + + if p.Content == "" { + p.LogInfo("内容为空") + return fmt.Errorf("内容为空") + } + + editorSelectors := []string{ + ".ql-editor", + "[contenteditable='true']", + ".rich-editor-content", + ".editor-content", + } + + var editor *rod.Element + var err error + for _, selector := range editorSelectors { + editor, err = p.WaitForElementVisible(selector, 10) + if err == nil && editor != nil { + p.LogInfo(fmt.Sprintf("找到编辑器: %s", selector)) + break + } + } + + if editor == nil { + return fmt.Errorf("未找到编辑器") + } + + // 清空编辑器 + //_, err = editor.Evaluate(&rod.EvalOptions{JS: `el => { el.innerHTML = ''; }`}) + p.SleepMs(500) + + // 点击编辑器获取焦点 + p.JSClick(editor) + p.SleepMs(500) + // 输入内容 + err = editor.Input(p.Content) + if err != nil { + return fmt.Errorf("输入内容失败: %v", err) + } + + p.LogInfo(fmt.Sprintf("内容已输入,长度: %d", len(p.Content))) + return nil +} + +func (p *SohuPublisher) setCover() error { + p.LogInfo("设置封面...") + + if p.ImagePath == "" { + p.LogInfo("未提供封面图片,尝试使用自动封面") + return p.setAutoCover() + } + + p.LogInfo(fmt.Sprintf("使用封面图片: %s", p.ImagePath)) + + coverSelectors := []string{ + ".cover-button .upload-file", + ".upload-file", + "[class*='cover'] .upload-file", + ".mp-upload", + } + + var uploadBtn *rod.Element + var err error + for _, selector := range coverSelectors { + uploadBtn, err = p.WaitForElementClickable(selector, 5) + if err == nil && uploadBtn != nil { + p.LogInfo(fmt.Sprintf("找到上传封面按钮: %s", selector)) + break + } + } + + if uploadBtn == nil { + p.LogInfo("未找到上传封面按钮") + return p.setAutoCover() + } + + if _, err := uploadBtn.Evaluate(&rod.EvalOptions{ + JS: `el => el.scrollIntoView({block: 'center'})`, + }); err != nil { + p.LogInfo(fmt.Sprintf("滚动到按钮失败: %v", err)) + } + p.SleepMs(500) + + fileInputSelectors := []string{ + "input[type='file'][accept*='image']", + ".mp-upload input[type='file']", + ".cover-button input[type='file']", + } + + var fileInput *rod.Element + for _, selector := range fileInputSelectors { + fileInput, err = p.Page.Element(selector) + if err == nil && fileInput != nil { + p.LogInfo(fmt.Sprintf("找到文件上传输入框: %s", selector)) + break + } + } + + if fileInput == nil { + p.LogInfo("未找到文件上传输入框") + return p.setAutoCover() + } + + if err := fileInput.SetFiles([]string{p.ImagePath}); err != nil { + p.LogInfo(fmt.Sprintf("上传图片失败: %v", err)) + return p.setAutoCover() + } + + p.LogInfo(fmt.Sprintf("封面图片已上传: %s", p.ImagePath)) + p.Sleep(3) + + cropConfirm, err := p.WaitForElement(".crop-btn .sure-btn, .confirm-btn", 5) + if err == nil && cropConfirm != nil { + if err := p.JSClick(cropConfirm); err == nil { + p.LogInfo("已确认裁剪") + } + p.SleepMs(1000) + } + + return nil +} + +func (p *SohuPublisher) setAutoCover() error { + p.LogInfo("设置为自动封面...") + + autoCoverSelectors := []string{ + "span:contains('自动')", + ".cover-title .auto-cover", + "[class*='auto']", + } + + for _, selector := range autoCoverSelectors { + autoElem, err := p.WaitForElement(selector, 3) + if err == nil && autoElem != nil { + if err := p.JSClick(autoElem); err == nil { + p.LogInfo("已选择自动封面") + p.SleepMs(500) + return nil + } + } + } + + p.LogInfo("未找到自动封面选项,跳过封面设置") + return nil +} + +func (p *SohuPublisher) setAbstract() error { + p.LogInfo("设置摘要...") + + abstractSelectors := []string{ + ".abstract-main textarea", + "textarea[placeholder*='摘要']", + ".abstract textarea", + } + + var abstractInput *rod.Element + var err error + for _, selector := range abstractSelectors { + abstractInput, err = p.WaitForElementVisible(selector, 3) + if err == nil && abstractInput != nil { + p.LogInfo(fmt.Sprintf("找到摘要输入框: %s", selector)) + break + } + } + + if abstractInput != nil { + if err := abstractInput.SelectAllText(); err == nil { + abstractInput.Input("") + } + p.SleepMs(300) + + abstractText := p.Content + if len(abstractText) > 120 { + abstractText = abstractText[:120] + } + abstractInput.Input(abstractText) + p.LogInfo("摘要已设置") + return nil + } + + p.LogInfo("未找到摘要输入框,跳过") + return nil +} + +func (p *SohuPublisher) setTags() error { + if len(p.Tags) == 0 { + p.LogInfo("无标签需要设置") + return nil + } + + p.LogInfo(fmt.Sprintf("设置标签: %v", p.Tags)) + + tagInputSelectors := []string{ + "input[placeholder*='标签']", + ".tag-input input", + "[class*='tag'] input", + } + + var tagInput *rod.Element + var err error + for _, selector := range tagInputSelectors { + tagInput, err = p.WaitForElement(selector, 3) + if err == nil && tagInput != nil { + p.LogInfo(fmt.Sprintf("找到标签输入框: %s", selector)) + break + } + } + + if tagInput != nil { + for _, tag := range p.Tags { + tagInput.Input(tag) + p.SleepMs(300) + tagInput.Input("\n") + p.SleepMs(300) + } + p.LogInfo("标签设置成功") + return nil + } + + p.LogInfo("未找到标签输入框(可能无需设置)") + return nil +} + +func (p *SohuPublisher) clickPublish() error { + p.LogInfo("点击发布按钮...") + + publishSelectors := []string{ + ".publish-report-btn.active", + "button:contains('发布')", + ".publish-btn", + ".submit-btn", + "[class*='publish'] button", + } + + var publishBtn *rod.Element + var err error + for _, selector := range publishSelectors { + publishBtn, err = p.WaitForElementClickable(selector, 5) + if err == nil && publishBtn != nil { + visible, _ := publishBtn.Visible() + if visible { + p.LogInfo(fmt.Sprintf("找到发布按钮: %s", selector)) + break + } + } + } + + if publishBtn == nil { + buttons, err := p.Page.Elements("button") + if err == nil { + for _, btn := range buttons { + text, _ := btn.Text() + if text == "发布" || strings.Contains(text, "发布") { + publishBtn = btn + p.LogInfo("通过遍历按钮找到发布按钮") + break + } + } + } + } + + if publishBtn == nil { + return fmt.Errorf("未找到发布按钮") + } + + if _, err := publishBtn.Evaluate(&rod.EvalOptions{ + JS: `el => el.scrollIntoView({block: 'center', behavior: 'smooth'})`, + }); err != nil { + p.LogInfo(fmt.Sprintf("滚动到按钮失败: %v", err)) + } + p.SleepMs(500) + + if err := publishBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := p.JSClick(publishBtn); err != nil { + return fmt.Errorf("点击发布按钮失败: %v", err) + } + } + + p.LogInfo("已点击发布按钮") + p.Sleep(3) + return nil +} + +func (p *SohuPublisher) waitForPublishResult(timeout int) (bool, string) { + p.LogInfo("等待发布结果...") + + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + currentURL := p.GetCurrentURL() + + if strings.Contains(currentURL, "contentManagement") { + p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) + return true, "发布成功" + } + + successMsgs, _ := p.Page.Elements(".el-message--success, .toast-success, .message-success, [class*='success']") + for _, elem := range successMsgs { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if strings.Contains(text, "成功") || strings.Contains(strings.ToLower(text), "success") || strings.Contains(text, "已发布") { + p.LogInfo(fmt.Sprintf("发布成功: %s", text)) + return true, text + } + } + } + + p.SleepMs(1000) + } + + return false, "发布结果未知(超时)" +} + +// InitPage 初始化页面 +func (p *SohuPublisher) InitPage() error { + + // 尝试加载cookies并检查登录状态 + if err := p.LoadCookies(); err == nil { + p.Page.MustNavigate(p.EditorURL) + p.WaitForPageReady(5) + p.Sleep(2) + } + // 统一检查登录状态 + if !p.CheckLoginStatus() { + p.LogInfo("未登录或登录已过期,需要重新登录") + return fmt.Errorf("需要登录") + } + p.SaveCookies() + return nil +} + +func (p *SohuPublisher) PublishNote() (bool, string) { + p.StartNote() + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + steps := []struct { + name string + fn func() error + }{ + {"初始化页面", p.InitPage}, + {"输入标题", p.inputTitle}, + {"导入内容", p.inputContent}, + } + + for _, step := range steps { + if err := step.fn(); err != nil { + p.LogStep(step.name, false, err.Error()) + return false, fmt.Sprintf("%s失败: %s", step.name, err.Error()) + } + p.LogStep(step.name, true, "") + } + + if err := p.clickPublish(); err != nil { + p.LogStep("点击发布", false, err.Error()) + return false, err.Error() + } + p.LogStep("点击发布", true, "") + + success, message := p.waitForPublishResult(60) + if success { + p.LogInfo(fmt.Sprintf("🎉 发布成功!%s", message)) + return true, message + } + p.LogError(fmt.Sprintf("发布失败: %s", message)) + return false, message +} diff --git a/internal/publisher/sphsp.go b/internal/publisher/sphsp.go new file mode 100644 index 0000000..4d10e0d --- /dev/null +++ b/internal/publisher/sphsp.go @@ -0,0 +1,582 @@ +package publisher + +import ( + "encoding/base64" + "fmt" + "geo/internal/config" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +type ShipinhaoVideoPublisher struct { + *BasePublisher + shortWait int + mediumWait int +} + +func NewShipinhaoVideoPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { + return &ShipinhaoVideoPublisher{ + BasePublisher: NewBasePublisher(task, cfg, logger), + shortWait: 1, + mediumWait: 3, + } +} + +func (p *ShipinhaoVideoPublisher) CheckLogin() (bool, string) { + p.LogInfo("检查登录状态...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.LoginedURL) + p.Sleep(3) + p.WaitForPageReady(5) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "已登录" + } + return false, "未登录" +} + +func (p *ShipinhaoVideoPublisher) CheckLoginStatus() bool { + currentURL := p.GetCurrentURL() + if strings.Contains(currentURL, "login") || strings.Contains(currentURL, "passport") { + return false + } + if strings.Contains(currentURL, "channels.weixin.qq.com") { + return true + } + return false +} + +func (p *ShipinhaoVideoPublisher) WaitLogin() (bool, string) { + p.LogInfo("开始等待登录...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.LoginURL) + p.Sleep(3) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "already_logged_in" + } + + p.LogInfo("请扫描二维码登录...") + + startTime := time.Now() + timeout := 120 + for time.Since(startTime) < time.Duration(timeout)*time.Second { + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "login_success" + } + p.SleepMs(2000) + } + + return false, "登录超时" +} + +func (p *ShipinhaoVideoPublisher) ensureInEditorIframe() error { + p.Page.Timeout(5 * time.Second).Eval(`() => { + const iframes = document.querySelectorAll('iframe[name="content"], wujie-app iframe, iframe[src*="content"]'); + if (iframes.length > 0) { + return true; + } + return false; + }`) + return nil +} + +func (p *ShipinhaoVideoPublisher) uploadViaCdpIntercept(filePath string) (bool, string) { + p.LogInfo("使用 CDP 协议拦截文件上传...") + + fileData, err := os.ReadFile(filePath) + if err != nil { + return false, fmt.Sprintf("读取文件失败: %v", err) + } + fileDataBase64 := base64.StdEncoding.EncodeToString(fileData) + fileName := filepath.Base(filePath) + + p.ensureInEditorIframe() + p.SleepMs(1000) + + 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 fileInput = document.querySelector('input[type="file"]'); + if (!fileInput) { + fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'video/*'; + fileInput.style.position = 'fixed'; + fileInput.style.top = '-1000px'; + fileInput.style.left = '-1000px'; + document.body.appendChild(fileInput); + } + + var dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + fileInput.files = dataTransfer.files; + + var changeEvent = new Event('change', { bubbles: true }); + fileInput.dispatchEvent(changeEvent); + + var inputEvent = new Event('input', { bubbles: true }); + fileInput.dispatchEvent(inputEvent); + + var uploadAreas = document.querySelectorAll('[class*="upload"], [class*="drop"]'); + for (var i = 0; i < uploadAreas.length; i++) { + var area = uploadAreas[i]; + if (area.offsetParent !== null) { + var dragOverEvent = new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer: dataTransfer + }); + area.dispatchEvent(dragOverEvent); + + var dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: dataTransfer + }); + area.dispatchEvent(dropEvent); + break; + } + } + + return {success: true, fileName: '%s'}; + })(); + `, fileDataBase64, fileName, fileName) + + result, err := p.Page.Eval(script) + if err != nil { + return false, err.Error() + } + p.LogInfo(fmt.Sprintf("CDP 注入完成: %v", result)) + return true, "文件已注入" +} + +func (p *ShipinhaoVideoPublisher) uploadViaDragEvent(filePath string) (bool, string) { + p.LogInfo("模拟拖拽事件上传...") + + fileData, err := os.ReadFile(filePath) + if err != nil { + return false, fmt.Sprintf("读取文件失败: %v", err) + } + fileDataBase64 := base64.StdEncoding.EncodeToString(fileData) + fileName := filepath.Base(filePath) + + p.ensureInEditorIframe() + p.SleepMs(1000) + + 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: '拖拽事件已触发'}; + })(); + `, fileDataBase64, fileName) + + result, err := p.Page.Eval(script) + if err != nil { + return false, err.Error() + } + p.LogInfo(fmt.Sprintf("拖拽事件已触发: %v", result)) + return true, "拖拽事件已触发" +} + +func (p *ShipinhaoVideoPublisher) uploadViaReactEvent(filePath string) (bool, string) { + p.LogInfo("尝试 React 事件上传...") + + fileData, err := os.ReadFile(filePath) + if err != nil { + return false, fmt.Sprintf("读取文件失败: %v", err) + } + fileDataBase64 := base64.StdEncoding.EncodeToString(fileData) + fileName := filepath.Base(filePath) + + p.ensureInEditorIframe() + + script := fmt.Sprintf(` + (function() { + var allElements = document.querySelectorAll('*'); + var uploadComponent = null; + + for (var i = 0; i < allElements.length; i++) { + var el = allElements[i]; + 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); + + var fileInput = document.querySelector('input[type="file"]'); + if (fileInput) { + fileInput.files = dataTransfer.files; + var event = new Event('change', {bubbles: true}); + fileInput.dispatchEvent(event); + } + + var syntheticEvent = new Event('change', {bubbles: true}); + syntheticEvent.target = {files: dataTransfer.files}; + uploadComponent.dispatchEvent(syntheticEvent); + + return {success: true}; + } + + return {success: false, message: '未找到 React 组件'}; + })(); + `, fileDataBase64, fileName) + + result, err := p.Page.Eval(script) + if err != nil { + return false, err.Error() + } + + p.LogInfo(fmt.Sprintf("React 事件触发结果: %v", result)) + return true, "React事件上传成功" +} + +func (p *ShipinhaoVideoPublisher) waitForUploadComplete(timeout int) (bool, string) { + p.LogInfo("等待视频上传完成...") + + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + uploadAreas, err := p.Page.Elements(".form-item.flex-start") + if err == nil && len(uploadAreas) > 0 { + p.LogInfo("视频上传成功") + p.SleepMs(2000) + return true, "上传完成" + } + p.SleepMs(2000) + } + + return false, "上传超时" +} + +func (p *ShipinhaoVideoPublisher) inputTitleAndDescription() (bool, string) { + fullContent := p.Title + if len(p.Tags) > 0 { + tagStr := "" + for _, tag := range p.Tags { + if tag != "" { + tagStr += fmt.Sprintf("#%s ", strings.TrimSpace(tag)) + } + } + tagStr = strings.TrimSpace(tagStr) + if tagStr != "" { + fullContent = fmt.Sprintf("%s %s", fullContent, tagStr) + } + } + + p.LogInfo(fmt.Sprintf("目标内容: %s", fullContent)) + + p.ensureInEditorIframe() + p.SleepMs(1000) + + jsScript := fmt.Sprintf(` + 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); + }); + + var nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; + if (nativeInputValueSetter && editor.tagName === 'INPUT') { + nativeInputValueSetter.call(editor, content); + editor.dispatchEvent(new Event('input', { bubbles: true })); + } else { + 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); + } + } + + var customEvent = new CustomEvent('react-change', { + bubbles: true, + detail: { value: content } + }); + editor.dispatchEvent(customEvent); + + console.log('Content set successfully, final value:', editor.innerText); + return true; + } + return setEditorContent('%s'); + `, strings.ReplaceAll(fullContent, "'", "\\'")) + + result, err := p.Page.Eval(jsScript) + if err != nil { + return false, err.Error() + } + + if result != nil && result.Value.Bool() { + p.LogInfo("✅ 通过JS终极方案成功设置内容") + p.SleepMs(1000) + return true, "内容输入成功" + } + + return false, "未找到编辑器元素" +} + +func (p *ShipinhaoVideoPublisher) clickPublish() (bool, string) { + p.LogInfo("点击发布按钮...") + + p.Page.Eval(`() => window.scrollTo(0, document.body.scrollHeight)`) + p.SleepMs(1000) + + p.ensureInEditorIframe() + p.SleepMs(500) + + publishScript := ` + 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(publishScript) + if err == nil && result != nil && result.Value.Bool() { + p.LogInfo("✅ 已点击发表按钮") + return true, "已点击发表" + } + + 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 { + btns, err := p.Page.Elements(selector) + if err == nil { + for _, btn := range btns { + visible, _ := btn.Visible() + if visible { + text, _ := btn.Text() + if text == "发表" || strings.Contains(text, "发表") { + p.JSClick(btn) + p.LogInfo(fmt.Sprintf("✅ 通过选择器 %s 点击发表按钮", selector)) + return true, "已点击发表" + } + } + } + } + } + + xpaths := []string{ + "//button[contains(text(), '发表')]", + "//button[contains(@class, 'primary') and contains(text(), '发表')]", + "//div[contains(@class, 'weui-desktop-btn_wrp')]//button", + } + + for _, xpath := range xpaths { + btns, err := p.Page.ElementsX(xpath) + if err == nil { + for _, btn := range btns { + visible, _ := btn.Visible() + if visible { + p.JSClick(btn) + p.LogInfo(fmt.Sprintf("✅ 通过XPath %s 点击发表按钮", xpath)) + return true, "已点击发表" + } + } + } + } + + p.LogError("❌ 所有方法都未找到发表按钮") + return false, "未找到发表按钮" +} + +func (p *ShipinhaoVideoPublisher) InitPage() error { + p.Page.MustNavigate(p.EditorURL) + p.Sleep(5) + + if err := p.LoadCookies(); err == nil { + p.RefreshPage() + p.Sleep(3) + if !p.CheckLoginStatus() { + return fmt.Errorf("需要登录") + } + p.LogInfo("登录成功") + } + + p.SaveCookies() + return nil +} + +func (p *ShipinhaoVideoPublisher) PublishNote() (bool, string) { + p.StartNote() + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + steps := []struct { + name string + fn func() error + }{ + {"初始化页面", p.InitPage}, + {"确保在iframe", func() error { return p.ensureInEditorIframe() }}, + } + + for _, step := range steps { + if err := step.fn(); err != nil { + p.LogStep(step.name, false, err.Error()) + return false, fmt.Sprintf("%s失败: %v", step.name, err) + } + p.LogStep(step.name, true, "") + } + + p.SleepMs(2000) + + filePath, _ := filepath.Abs(p.SourcePath) + uploadSuccess := false + uploadMessage := "" + + methods := []struct { + name string + fn func(string) (bool, string) + }{ + {"CDP拦截上传", p.uploadViaCdpIntercept}, + {"拖拽事件上传", p.uploadViaDragEvent}, + + {"React事件上传", p.uploadViaReactEvent}, + } + + for _, method := range methods { + p.LogInfo(fmt.Sprintf("尝试 %s...", method.name)) + uploadSuccess, uploadMessage = method.fn(filePath) + if uploadSuccess { + p.LogInfo(fmt.Sprintf("%s 成功", method.name)) + break + } + p.LogWarning(fmt.Sprintf("%s 失败: %s", method.name, uploadMessage)) + p.SleepMs(1000) + } + + if !uploadSuccess { + return false, fmt.Sprintf("所有上传方法均失败: %s", uploadMessage) + } + + p.waitForUploadComplete(180) + + p.inputTitleAndDescription() + + success, message := p.clickPublish() + if !success { + return false, message + } + + p.Sleep(10) + + currentURL := p.GetCurrentURL() + if strings.Contains(currentURL, "https://channels.weixin.qq.com/platform/post/list") { + p.LogInfo("🎉 发布完成") + return true, "发布成功" + } + + return false, "发布失败" +} + +func (p *ShipinhaoVideoPublisher) LogWarning(message string) { + p.Logger.Printf("⚠️ %s", message) +} diff --git a/internal/publisher/toutiao.go b/internal/publisher/toutiao.go index 1c1f79d..e2320a5 100644 --- a/internal/publisher/toutiao.go +++ b/internal/publisher/toutiao.go @@ -317,75 +317,6 @@ func (p *ToutiaoPublisher) inputContent() error { return nil } -// SetContentEditableHTML 设置 contenteditable 元素的 HTML 内容 -func (p *ToutiaoPublisher) SetContentEditableHTML(element *rod.Element, html string) error { - _, err := element.Evaluate(&rod.EvalOptions{ - JS: `(el, val) => { el.innerHTML = val; el.dispatchEvent(new Event('input', {bubbles: true})); }`, - JSArgs: []interface{}{html}, - }) - return err -} - -// findFileInput 查找文件上传输入框 -func (p *ToutiaoPublisher) findFileInput() (*rod.Element, error) { - selectors := []string{ - "input[type='file']", - ".byte-drawer-inner input[type='file']", - "input[accept*='image']", - ".upload-input input[type='file']", - "button input[type='file']", - } - - for _, selector := range selectors { - el, err := p.Page.Element(selector) - if err == nil && el != nil { - p.LogInfo(fmt.Sprintf("找到文件上传输入框: %s", selector)) - return el, nil - } - } - - return nil, fmt.Errorf("未找到文件上传输入框") -} - -// clickConfirmButton 点击确认按钮(带重试机制) -func (p *ToutiaoPublisher) clickConfirmButton() error { - for attempt := 1; attempt <= 10; attempt++ { - p.LogInfo(fmt.Sprintf("第 %d 次尝试点击确认按钮", attempt)) - - // 查找确认按钮 - confirmBtn, err := p.Page.Element("button[data-e2e='imageUploadConfirm-btn']") - if err == nil && confirmBtn != nil { - // 检查按钮是否可用 - if err := p.JSClick(confirmBtn); err != nil { - p.LogInfo(fmt.Sprintf("点击确认按钮失败: %v", err)) - } else { - p.LogInfo("已点击确认按钮") - p.SleepMs(1000) - - // 检查弹窗是否已关闭 - if p.isDrawerClosed() { - p.LogInfo("弹窗已成功关闭,封面设置完成") - return nil - } - } - } else { - p.LogInfo(fmt.Sprintf("第 %d 次尝试:未找到确认按钮", attempt)) - } - - if attempt < 10 { - time.Sleep(2 * time.Second) - } - } - - // 最终检查弹窗状态 - if p.isDrawerClosed() { - p.LogInfo("弹窗已关闭,封面设置成功") - return nil - } - - return fmt.Errorf("确认按钮点击后弹窗未关闭") -} - // isDrawerClosed 检查弹窗是否已关闭 func (p *ToutiaoPublisher) isDrawerClosed() bool { drawerWrappers, err := p.Page.Elements(".byte-drawer-wrapper") @@ -462,7 +393,7 @@ func (p *ToutiaoPublisher) clickPublish() error { func (p *ToutiaoPublisher) waitForPublishResult() (bool, string) { p.LogInfo("等待发布结果...") - for attempt := 0; attempt < 60; attempt++ { + for attempt := 0; attempt < p.MaxRetries; attempt++ { currentURL := p.GetCurrentURL() p.LogInfo(fmt.Sprintf("第 %d 次检查 - URL: %s", attempt+1, currentURL)) @@ -509,7 +440,7 @@ func (p *ToutiaoPublisher) waitForPublishResult() (bool, string) { } } - p.SleepMs(1000) + p.SleepMs(p.RetryDelay) } p.LogWarning("发布结果未知") @@ -519,14 +450,11 @@ func (p *ToutiaoPublisher) waitForPublishResult() (bool, string) { // InitPage 初始化页面 func (p *ToutiaoPublisher) InitPage() error { // 访问发布页面 - p.Page.MustNavigate(p.EditorURL) - p.Sleep(2) - p.WaitForPageReady(5) // 尝试加载cookies并检查登录状态 if err := p.LoadCookies(); err == nil { - p.RefreshPage() - p.Sleep(2) + p.Page.MustNavigate(p.EditorURL) + p.WaitForPageReady(5) if p.CheckLoginStatus() { p.SaveCookies() return nil @@ -553,7 +481,6 @@ func (p *ToutiaoPublisher) PublishNote() (bool, string) { }{ {"初始化页面", p.InitPage}, {"输入内容", p.inputContent}, - //{"上传封面", p.uploadCover}, {"输入标题", p.inputTitle}, {"点击发布", p.clickPublish}, } diff --git a/internal/publisher/wyh.go b/internal/publisher/wyh.go new file mode 100644 index 0000000..f8702d2 --- /dev/null +++ b/internal/publisher/wyh.go @@ -0,0 +1,621 @@ +package publisher + +import ( + "fmt" + "geo/internal/config" + "log" + "os" + "strings" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +type WangyiPublisher struct { + *BasePublisher + Category string + IsOriginal bool +} + +// NewWangyiPublisher 构造函数 +func NewWangyiPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { + return &WangyiPublisher{ + BasePublisher: NewBasePublisher(task, cfg, logger), + Category: "", + IsOriginal: true, + } +} + +func (p *WangyiPublisher) CheckLogin() (bool, string) { + p.LogInfo("检查登录状态...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.EditorURL) + p.Sleep(3) + p.WaitForPageReady(5) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "已登录" + } + return false, "未登录" +} + +func (p *WangyiPublisher) CheckLoginStatus() bool { + currentURL := p.GetCurrentURL() + + // 如果在登录页面,未登录 + if strings.Contains(currentURL, p.LoginURL) { + return false + } + return true +} + +func (p *WangyiPublisher) WaitLogin() (bool, string) { + p.LogInfo("开始等待登录...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + // 先尝试访问已登录页面 + if p.LoginedURL != "" { + p.Page.MustNavigate(p.LoginedURL) + p.Sleep(3) + + if p.CheckLoginStatus() { + p.SaveCookies() + p.LogInfo("已有登录状态") + return true, "already_logged_in" + } + } + + // 未登录,跳转到登录页 + if p.LoginURL != "" { + p.Page.MustNavigate(p.LoginURL) + p.WaitForPageReady(5) + } + + p.LogInfo("请扫码登录...") + + // 等待登录完成,最多120秒 + for i := 0; i < 120; i++ { + currentURL := p.GetCurrentURL() + + // 检查是否跳转到发布页面或主页 + if p.EditorURL != "" && strings.Contains(currentURL, p.EditorURL) { + p.SaveCookies() + p.LogInfo("登录成功") + return true, "login_success" + } + if p.LoginedURL != "" && strings.Contains(currentURL, p.LoginedURL) { + p.SaveCookies() + p.LogInfo("登录成功") + return true, "login_success" + } + + // 检查登录状态 + if p.CheckLoginStatus() { + p.SaveCookies() + p.LogInfo("登录成功") + return true, "login_success" + } + + time.Sleep(1 * time.Second) + } + + return false, "登录超时,请检查网络或账号状态" +} + +// inputTitle 输入标题 +func (p *WangyiPublisher) inputTitle() error { + p.LogInfo("输入文章标题...") + + titleSelectors := []string{ + "textarea[placeholder*='标题']", + "input[placeholder*='标题']", + } + + var titleInput *rod.Element + var err error + + for _, selector := range titleSelectors { + titleInput, err = p.WaitForElementVisible(selector, 5) + if err == nil && titleInput != nil { + p.LogInfo(fmt.Sprintf("找到标题输入框: %s", selector)) + break + } + } + + if titleInput == nil { + return fmt.Errorf("未找到标题输入框") + } + + // 点击获取焦点 + if err := titleInput.Click(proto.InputMouseButtonLeft, 1); err != nil { + return fmt.Errorf("点击标题框失败: %v", err) + } + p.SleepMs(300) + + // 清空输入框 + if err := p.ClearInput(titleInput); err != nil { + titleInput.Input("") + } + p.SleepMs(300) + + // 输入标题 + if err := p.SetInputValue(titleInput, p.Title); err != nil { + titleInput.Input(p.Title) + } + + p.LogInfo(fmt.Sprintf("标题已输入: %s", p.Title)) + p.SleepMs(500) + + return nil +} + +// insertImageToWord 将图片插入到Word文档头部 +func (p *WangyiPublisher) insertImageToWord(docPath, imagePath string) (string, error) { + // 由于Go中处理Word文档需要额外库,这里先返回原路径 + // 如果需要此功能,可以使用 unioffice 或 excelize 等库 + p.LogInfo(fmt.Sprintf("插入图片到Word: %s -> %s", imagePath, docPath)) + return docPath, nil +} + +// importDocument 导入Word文档 +func (p *WangyiPublisher) importDocument() error { + if p.SourcePath == "" { + p.LogInfo("未提供文档路径,使用内容文本") + return p.inputContentDirect() + } + + if _, err := os.Stat(p.SourcePath); os.IsNotExist(err) { + p.LogWarning(fmt.Sprintf("文档不存在: %s", p.SourcePath)) + return p.inputContentDirect() + } + + p.LogInfo(fmt.Sprintf("尝试导入文档: %s", p.SourcePath)) + + // 查找导入文档按钮 + importSelectors := []string{ + ".ne-rich-editor-upload-button", + "[class*='rich-editor-upload']", + "label[for='ne-rich-editor-upload-input']", + ".rich-editor-panel-item", + } + + var importBtn *rod.Element + var err error + + for _, selector := range importSelectors { + importBtn, err = p.WaitForElementClickable(selector, 5) + if err == nil && importBtn != nil { + p.LogInfo(fmt.Sprintf("找到导入文档按钮: %s", selector)) + break + } + } + + if importBtn == nil { + // 尝试通过文本查找 + importBtn, err = p.Page.Element("button:contains('导入文档')") + if err != nil { + p.LogWarning("未找到导入文档按钮,使用直接输入内容") + return p.inputContentDirect() + } + } + + p.SleepMs(500) + + // 查找文件上传输入框 + fileInputSelectors := []string{ + "#ne-rich-editor-upload-input", + "input[type='file'][accept*='.doc']", + "input[type='file'][accept*='.docx']", + ".ne-rich-editor-upload-button input[type='file']", + } + + var fileInput *rod.Element + for _, selector := range fileInputSelectors { + fileInput, err = p.Page.Element(selector) + if err == nil && fileInput != nil { + p.LogInfo(fmt.Sprintf("找到文件上传输入框: %s", selector)) + break + } + } + + if fileInput == nil { + p.LogWarning("未找到文件上传输入框,使用直接输入内容") + return p.inputContentDirect() + } + + // 上传文件 + if err := fileInput.SetFiles([]string{p.SourcePath}); err != nil { + p.LogWarning(fmt.Sprintf("上传文件失败: %v,使用直接输入内容", err)) + return p.inputContentDirect() + } + p.LogInfo(fmt.Sprintf("文档已上传: %s", p.SourcePath)) + p.SleepMs(3000) + + // 等待导入完成 + for attempt := 0; attempt < 30; attempt++ { + // 检查内容编辑器是否有内容 + contentEditor, err := p.Page.Element(".rich-editor-stage, .ProseMirror, [contenteditable='true']") + if err == nil && contentEditor != nil { + text, _ := contentEditor.Text() + if len(text) > 0 { + p.LogInfo("文档导入成功") + return nil + } + } + p.SleepMs(1000) + } + + p.LogInfo("文档导入完成") + return nil +} + +// inputContentDirect 直接输入内容 +func (p *WangyiPublisher) inputContentDirect() error { + p.LogInfo("直接输入文章内容...") + + // 查找内容编辑器 + contentSelectors := []string{ + ".rich-editor-stage", + ".ProseMirror", + "[contenteditable='true']", + } + + var contentEditor *rod.Element + var err error + + for _, selector := range contentSelectors { + contentEditor, err = p.WaitForElementVisible(selector, 10) + if err == nil && contentEditor != nil { + p.LogInfo(fmt.Sprintf("找到内容编辑器: %s", selector)) + break + } + } + + if contentEditor == nil { + return fmt.Errorf("未找到内容编辑器") + } + + // 点击获取焦点 + if err := contentEditor.Click(proto.InputMouseButtonLeft, 1); err != nil { + return fmt.Errorf("点击编辑器失败: %v", err) + } + p.SleepMs(500) + + // 清空现有内容 + if err := p.ClearContentEditable(contentEditor); err != nil { + p.LogInfo(fmt.Sprintf("清空编辑器失败: %v", err)) + } + p.SleepMs(300) + + // 输入内容 + if err := p.SetContentEditable(contentEditor, p.Content); err != nil { + contentEditor.Input(p.Content) + } + + p.LogInfo(fmt.Sprintf("内容已输入,长度: %d", len(p.Content))) + return nil +} + +// setCover 设置封面为自动模式 +func (p *WangyiPublisher) setCover() error { + p.LogInfo("设置封面为自动模式...") + + // 先找到 class="post-footer__container" 的 div,点击 + footerDiv, err := p.Page.ElementX("//span[contains(text(), '设置区')]") + if err != nil { + p.LogWarning("未找到 设置区") + return fmt.Errorf("未找到设置区") + } + + // 点击这个div + if err := p.JSClick(footerDiv); err != nil { + p.LogInfo(fmt.Sprintf("点击 设置区 失败: %v", err)) + } + p.SleepMs(2000) + + // 查找包含"自动"文本的span + autoSpan, err := p.Page.ElementX("//span[contains(text(), '自动')]") + if err != nil { + p.LogWarning("未找到包含'自动'文本的span") + return fmt.Errorf("未找到包含'自动'文本的span") + } + + p.SleepMs(500) + // + // 查找父级label并点击 + parentLabel, err := autoSpan.Parent() + if err != nil { + return fmt.Errorf("未找到父级label元素: %v", err) + } + + if err := p.JSClick(parentLabel); err != nil { + return fmt.Errorf("点击自动封面选项失败: %v", err) + } + + p.LogInfo("已点击自动封面选项") + p.SleepMs(1000) + + return nil +} + +// clickPublish 点击发布按钮 +func (p *WangyiPublisher) clickPublish() error { + p.LogInfo("点击发布按钮...") + + publishSelectors := []string{ + "button:contains('发布')", + "button.ne-button:contains('发布')", + "button.primary_button", + "button.ne-button-color-primary", + ".netease-button.primary_button", + ".publish-btn", + ".submit-btn", + } + + var publishBtn *rod.Element + var err error + + for _, selector := range publishSelectors { + publishBtn, err = p.WaitForElementClickable(selector, 3) + if err == nil && publishBtn != nil { + p.LogInfo(fmt.Sprintf("找到发布按钮: %s", selector)) + break + } + } + + if publishBtn == nil { + // 遍历所有按钮查找文本为"发布"的 + buttons, err := p.Page.Elements("button") + if err == nil { + for _, btn := range buttons { + text, _ := btn.Text() + if text == "发布" || strings.Contains(text, "发布") { + publishBtn = btn + p.LogInfo("通过遍历按钮找到发布按钮") + break + } + } + } + } + + if publishBtn == nil { + return fmt.Errorf("未找到发布按钮") + } + + p.SleepMs(500) + + // 点击发布按钮 + if err := p.JSClick(publishBtn); err != nil { + return fmt.Errorf("点击发布按钮失败: %v", err) + } + + p.LogInfo("已点击发布按钮") + p.SleepMs(3000) + + return nil +} + +// waitForPublishResult 等待发布结果 +func (p *WangyiPublisher) waitForPublishResult() (bool, string) { + p.LogInfo("等待发布结果...") + + timeout := 60 + retryInterval := 5 + maxRetries := 5 + lastClickTime := int64(0) + retryCount := 0 + + startTime := time.Now() + errorText := "" + + for time.Since(startTime).Seconds() < float64(timeout) { + currentURL := p.GetCurrentURL() + + // 检查是否发布成功 + successKeywords := []string{"success", "article-manage", "content-manage", "list", "article/list"} + for _, keyword := range successKeywords { + if strings.Contains(currentURL, keyword) { + p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) + return true, "发布成功" + } + } + + // 检查成功提示 + successSelectors := []string{ + "[class*='success']", + ".toast-success", + ".message-success", + ".ne-message-success", + } + for _, selector := range successSelectors { + msgs, err := p.Page.Elements(selector) + if err == nil { + for _, elem := range msgs { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if text != "" && (strings.Contains(text, "成功") || strings.Contains(text, "已发布")) { + p.LogInfo(fmt.Sprintf("发布成功: %s", text)) + return true, text + } + } + } + } + } + + // 检查错误提示 + errorFound := false + errorSelectors := []string{ + "[class*='error']", + ".toast-error", + ".message-error", + ".ne-message-error", + } + for _, selector := range errorSelectors { + msgs, err := p.Page.Elements(selector) + if err == nil { + for _, elem := range msgs { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if text != "" && (strings.Contains(text, "失败") || strings.Contains(text, "error")) { + errorText = text + errorFound = true + p.LogError(fmt.Sprintf("发布失败: %s", errorText)) + break + } + } + } + } + if errorFound { + break + } + } + + // 检查是否需要重试 + currentTime := time.Now().Unix() + if currentTime-lastClickTime >= int64(retryInterval) && retryCount < maxRetries { + if strings.Contains(currentURL, p.EditorURL) { + p.LogInfo(fmt.Sprintf("第 %d 次重试,重新点击发布按钮...", retryCount+1)) + + // 如果有错误提示,先尝试关闭 + if errorFound { + closeBtns, _ := p.Page.Elements(".close-btn, .ne-message-close, [class*='close']") + for _, btn := range closeBtns { + visible, _ := btn.Visible() + if visible { + p.JSClick(btn) + p.LogInfo("关闭错误提示") + p.SleepMs(1000) + break + } + } + } + + // 重新点击发布按钮 + if err := p.clickPublish(); err == nil { + retryCount++ + lastClickTime = currentTime + errorFound = false + p.LogInfo(fmt.Sprintf("第 %d 次重试已点击发布按钮,继续等待结果...", retryCount)) + } else { + p.LogWarning(fmt.Sprintf("第 %d 次重试点击发布按钮失败", retryCount+1)) + lastClickTime = currentTime + retryCount++ + } + + p.SleepMs(2000) + continue + } else if errorFound { + return false, fmt.Sprintf("发布失败: %s", errorText) + } + } + + p.SleepMs(1000) + } + + // 超时后最后一次尝试 + if retryCount < maxRetries { + p.LogInfo("超时前最后一次尝试点击发布按钮...") + p.clickPublish() + p.SleepMs(5000) + + // 再次检查结果 + successSelectors := []string{ + "[class*='success']", + ".toast-success", + ".message-success", + } + for _, selector := range successSelectors { + msgs, err := p.Page.Elements(selector) + if err == nil { + for _, elem := range msgs { + text, _ := elem.Text() + if text != "" && strings.Contains(text, "成功") { + return true, text + } + } + } + } + } + + return false, "发布结果未知(超时)" +} + +// InitPage 初始化页面 +func (p *WangyiPublisher) InitPage() error { + + // 尝试加载cookies并检查登录状态 + if err := p.LoadCookies(); err == nil { + p.Page.MustNavigate(p.EditorURL) + p.WaitForPageReady(5) + p.Sleep(2) + } + // 统一检查登录状态 + if !p.CheckLoginStatus() { + p.LogInfo("未登录或登录已过期,需要重新登录") + return fmt.Errorf("需要登录") + } + p.SaveCookies() + return nil +} + +// PublishNote 发布文章 +func (p *WangyiPublisher) PublishNote() (bool, string) { + p.StartNote() + + // 初始化浏览器 + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + // 执行发布流程 + steps := []struct { + name string + fn func() error + }{ + {"初始化页面", p.InitPage}, + {"输入标题", p.inputTitle}, + {"导入内容", p.importDocument}, + {"设置封面", p.setCover}, + {"点击发布", p.clickPublish}, + } + + for _, step := range steps { + if err := step.fn(); err != nil { + // 失败时截图 + screenshotFile := fmt.Sprintf("wangyi_screenshot_%s_%d.png", step.name, time.Now().Unix()) + p.Screenshot(screenshotFile) + p.LogStep(step.name, false, err.Error()) + return false, fmt.Sprintf("%s失败: %v", step.name, err) + } + p.LogStep(step.name, true, "") + p.SleepMs(500) + } + + // 等待发布结果 + success, message := p.waitForPublishResult() + if success { + p.LogInfo(fmt.Sprintf("🎉 发布成功!%s", message)) + } else { + p.LogError(fmt.Sprintf("发布失败: %s", message)) + } + return success, message +} + +// LogWarning 记录警告日志 +func (p *WangyiPublisher) LogWarning(message string) { + p.Logger.Printf("⚠️ %s", message) +} diff --git a/internal/publisher/xhs.go b/internal/publisher/xhs.go index 34d9544..36ee130 100644 --- a/internal/publisher/xhs.go +++ b/internal/publisher/xhs.go @@ -69,6 +69,7 @@ func (p *XiaohongshuPublisher) WaitLogin() (bool, string) { p.LogInfo("登录成功") return true, "login_success" } + p.SleepMs(1000) } return false, "登录超时" @@ -404,12 +405,11 @@ func (p *XiaohongshuPublisher) ClickUploadBotton() error { } func (p *XiaohongshuPublisher) InitPage() error { - // 访问发布页面 - p.Page.MustNavigate(p.EditorURL) - p.WaitForPageReady(5) + // 尝试加载cookies并检查登录状态 if err := p.LoadCookies(); err == nil { - p.RefreshPage() + p.Page.MustNavigate(p.EditorURL) + p.WaitForPageReady(5) p.Sleep(2) } // 统一检查登录状态 diff --git a/internal/publisher/xhssp.go b/internal/publisher/xhssp.go new file mode 100644 index 0000000..93c59d8 --- /dev/null +++ b/internal/publisher/xhssp.go @@ -0,0 +1,384 @@ +package publisher + +import ( + "fmt" + "geo/internal/config" + "log" + "os" + "strings" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +type XiaohongshuVideoPublisher struct { + *BasePublisher + shortWait int + mediumWait int + longWait int +} + +func NewXiaohongshuVideoPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { + return &XiaohongshuVideoPublisher{ + BasePublisher: NewBasePublisher(task, cfg, logger), + shortWait: 1, + mediumWait: 3, + longWait: 5, + } +} + +func (p *XiaohongshuVideoPublisher) CheckLogin() (bool, string) { + p.LogInfo("检查登录状态...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.LoginedURL) + p.Sleep(3) + p.WaitForPageReady(5) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "已登录" + } + return false, "未登录" +} + +func (p *XiaohongshuVideoPublisher) WaitLogin() (bool, string) { + p.LogInfo("开始等待登录...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.LoginedURL) + p.Sleep(3) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "already_logged_in" + } + + startTime := time.Now() + timeout := 120 + for time.Since(startTime) < time.Duration(timeout)*time.Second { + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "login_success" + } + p.SleepMs(1000) + } + + return false, "登录超时,请检查网络或账号状态" +} + +func (p *XiaohongshuVideoPublisher) waitForEditorReady(timeout int) bool { + p.LogInfo("等待编辑器加载...") + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + uploadArea, err := p.Page.Element(".upload-wrapper") + if err == nil && uploadArea != nil { + p.LogInfo("编辑器加载完成") + return true + } + p.SleepMs(1000) + } + p.LogInfo("编辑器加载超时") + return false +} + +func (p *XiaohongshuVideoPublisher) uploadVideo() error { + if p.SourcePath == "" { + return fmt.Errorf("视频不存在") + } + if _, err := os.Stat(p.SourcePath); os.IsNotExist(err) { + return fmt.Errorf("视频不存在") + } + + p.LogInfo(fmt.Sprintf("开始上传视频: %s", p.SourcePath)) + + fileInputSelectors := []string{ + "input[type='file'][accept*='video']", + ".upload-input", + "input[accept*='mp4']", + "input[accept*='video/*']", + } + + fileInput, err := p.Page.Element(".upload-input") + if err != nil { + fmt.Errorf("找到文件上传输入框") + } + + if fileInput == nil { + uploadArea, err := p.WaitForElementVisible(".video-plugin-title-action", 5) + if err == nil && uploadArea != nil { + p.LogInfo("点击上传区域") + p.JSClick(uploadArea) + p.SleepMs(1000) + + for _, selector := range fileInputSelectors { + fileInput, _ = p.Page.Element(selector) + if fileInput != nil { + break + } + } + } + } + + if fileInput == nil { + return fmt.Errorf("未找到文件上传输入框") + } + + if err := fileInput.SetFiles([]string{p.SourcePath}); err != nil { + return fmt.Errorf("上传视频失败: %v", err) + } + p.LogInfo(fmt.Sprintf("视频文件已选择: %s", p.SourcePath)) + + return p.waitForUploadComplete() +} + +func (p *XiaohongshuVideoPublisher) waitForUploadComplete() error { + p.LogInfo("等待视频上传完成...") + + for i := 0; i < 300; i++ { + publishBtn, err := p.Page.Element(".publish-page-publish-btn button.bg-red") + if err == nil && publishBtn != nil { + text, _ := publishBtn.Text() + if text == "发布" { + p.LogInfo("发布按钮已可点击,视频上传完成") + return nil + } + } + + errorElem, err := p.Page.Element("[class*='error'], .toast-error") + if err == nil && errorElem != nil { + visible, _ := errorElem.Visible() + if visible { + text, _ := errorElem.Text() + if text != "" { + return fmt.Errorf("上传失败: %s", text) + } + } + } + + p.SleepMs(1000) + } + + return fmt.Errorf("上传超时") +} + +func (p *XiaohongshuVideoPublisher) inputTitle() error { + p.LogInfo(fmt.Sprintf("输入视频标题: %s", p.Title)) + + titleSelectors := []string{ + "textarea.d-input", + ".d-input input", + "input[type='text']", + "textarea", + } + + for _, selector := range titleSelectors { + titleInput, err := p.WaitForElementVisible(selector, 5) + if err == nil && titleInput != nil { + p.LogInfo(fmt.Sprintf("找到标题输入框: %s", selector)) + + p.ClearInput(titleInput) + p.SleepMs(300) + titleInput.Input("") + p.SleepMs(300) + titleInput.Input(p.Title) + + p.SleepMs(500) + return nil + } + } + + return fmt.Errorf("未找到标题输入框") +} + +func (p *XiaohongshuVideoPublisher) inputDescription() error { + p.LogInfo("输入视频描述...") + + fullDescription := p.Content + if len(p.Tags) > 0 { + tagStr := "" + for _, tag := range p.Tags { + if tag != "" { + tagStr += fmt.Sprintf("#%s ", tag) + } + } + tagStr = strings.TrimSpace(tagStr) + if fullDescription != "" { + fullDescription = fmt.Sprintf("%s\n\n%s", tagStr, fullDescription) + } else { + fullDescription = tagStr + } + } + + p.LogInfo(fmt.Sprintf("描述内容: %s...", fullDescription[:min(len(fullDescription), 100)])) + + editorSelectors := []string{ + ".tiptap.ProseMirror", + ".ProseMirror", + "[contenteditable='true']", + ".editor-content .tiptap", + } + + for _, selector := range editorSelectors { + editor, err := p.WaitForElementVisible(selector, 5) + if err == nil && editor != nil { + p.LogInfo(fmt.Sprintf("找到编辑器: %s", selector)) + + editor.Click(proto.InputMouseButtonLeft, 1) + p.SleepMs(500) + + p.SetContentEditable(editor, fullDescription) + + p.SleepMs(1000) + return nil + } + } + + return fmt.Errorf("未找到描述输入框") +} + +func (p *XiaohongshuVideoPublisher) clickPublish() error { + p.LogInfo("点击发布按钮...") + + p.Page.Eval(`() => window.scrollTo(0, document.body.scrollHeight)`) + p.SleepMs(1000) + + var publishBtn *rod.Element + + publishContainer, err := p.WaitForElementVisible(".publish-page-publish-btn", 5) + if err == nil && publishContainer != nil { + buttons, _ := publishContainer.Elements("button") + for _, btn := range buttons { + text, _ := btn.Text() + if text == "发布" { + publishBtn = btn + p.LogInfo("找到发布按钮") + break + } + } + } + + if publishBtn == nil { + allButtons, _ := p.Page.Elements("button") + for _, btn := range allButtons { + text, _ := btn.Text() + if text == "发布" && !strings.Contains(text, "暂存") { + publishBtn = btn + p.LogInfo("通过遍历找到发布按钮") + break + } + } + } + + if publishBtn == nil { + return fmt.Errorf("未找到发布按钮") + } + + publishBtn.ScrollIntoView() + p.SleepMs(500) + + if err := publishBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + p.JSClick(publishBtn) + p.LogInfo("使用JS点击发布按钮") + } else { + p.LogInfo("已点击发布按钮") + } + + p.SleepMs(2000) + return nil +} + +func (p *XiaohongshuVideoPublisher) waitForPublishResult(timeout int) (bool, string) { + p.LogInfo("等待发布结果...") + + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + currentURL := p.GetCurrentURL() + + successKeywords := []string{"success", "content/manage", "work-management"} + for _, keyword := range successKeywords { + if strings.Contains(currentURL, keyword) { + p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) + return true, "发布成功" + } + } + + toasts, _ := p.Page.Elements(".semi-toast-content, [class*='toast']") + for _, toast := range toasts { + visible, _ := toast.Visible() + if visible { + text, _ := toast.Text() + if strings.Contains(text, "成功") || strings.Contains(text, "已发布") { + p.LogInfo(fmt.Sprintf("发布成功: %s", text)) + return true, text + } else if strings.Contains(text, "失败") { + p.LogError(fmt.Sprintf("发布失败: %s", text)) + return false, text + } + } + } + + if strings.Contains(currentURL, "publish") && time.Since(startTime) > 10*time.Second { + errorMsgs, _ := p.Page.Elements("[class*='error'], .toast-error") + for _, elem := range errorMsgs { + visible, _ := elem.Visible() + if visible { + text, _ := elem.Text() + if text != "" { + return false, text + } + } + } + } + + p.SleepMs(2000) + } + + return false, "发布结果未知(超时)" +} + +func (p *XiaohongshuVideoPublisher) PublishNote() (bool, string) { + p.StartNote() + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + steps := []struct { + name string + fn func() error + }{ + {"初始化页面", p.InitPage}, + {"上传视频", p.uploadVideo}, + {"输入标题", p.inputTitle}, + {"输入描述", p.inputDescription}, + {"点击发布", p.clickPublish}, + } + + for _, step := range steps { + if err := step.fn(); err != nil { + p.LogStep(step.name, false, err.Error()) + return false, fmt.Sprintf("%s失败: %v", step.name, err) + } + p.LogStep(step.name, true, "") + p.SleepMs(1000) + } + + success, message := p.waitForPublishResult(60) + if success { + p.LogInfo(fmt.Sprintf("🎉 发布成功!%s", message)) + return true, message + } + p.LogError(fmt.Sprintf("发布失败: %s", message)) + return false, message +} diff --git a/internal/publisher/zh.go b/internal/publisher/zh.go new file mode 100644 index 0000000..f969195 --- /dev/null +++ b/internal/publisher/zh.go @@ -0,0 +1,410 @@ +package publisher + +import ( + "fmt" + "geo/internal/config" + "log" + "strings" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +type ZhihuPublisher struct { + *BasePublisher +} + +func NewZhihuPublisher(task *TaskParams, cfg *config.Config, logger *log.Logger) PublisherInerface { + return &ZhihuPublisher{NewBasePublisher(task, cfg, logger)} +} + +func (p *ZhihuPublisher) CheckLogin() (bool, string) { + p.LogInfo("检查登录状态...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + p.Page.MustNavigate(p.EditorURL) + p.Sleep(3) + p.WaitForPageReady(5) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "已登录" + } + return false, "未登录" +} + +func (p *ZhihuPublisher) CheckLoginStatus() bool { + currentURL := p.GetCurrentURL() + if strings.Contains(currentURL, p.LoginURL) { + return false + } + return true +} + +func (p *ZhihuPublisher) WaitLogin() (bool, string) { + p.LogInfo("开始等待登录...") + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + if p.EditorURL != "" { + p.Page.MustNavigate(p.EditorURL) + p.Sleep(3) + + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "already_logged_in" + } + } + + startTime := time.Now() + timeout := 240 + for time.Since(startTime) < time.Duration(timeout)*time.Second { + currentURL := p.GetCurrentURL() + if p.EditorURL != "" && strings.Contains(currentURL, p.EditorURL) { + p.SaveCookies() + return true, "login_success" + } + if p.LoginedURL != "" && strings.Contains(currentURL, p.LoginedURL) { + p.SaveCookies() + return true, "login_success" + } + if p.CheckLoginStatus() { + p.SaveCookies() + return true, "login_success" + } + p.SleepMs(1000) + } + + return false, "登录超时,请检查网络或账号状态" +} + +func (p *ZhihuPublisher) waitForEditorReady(timeout int) bool { + p.LogInfo("等待编辑器加载...") + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + titleSelectors := []string{ + ".WriteIndex-titleInput textarea", + ".DraftEditor-root", + ".public-DraftEditor-content", + "[contenteditable='true']", + } + for _, selector := range titleSelectors { + el, err := p.Page.Element(selector) + if err == nil && el != nil { + visible, _ := el.Visible() + if visible { + p.LogInfo("编辑器加载完成") + return true + } + } + } + p.SleepMs(1000) + } + p.LogInfo("编辑器加载超时") + return false +} + +func (p *ZhihuPublisher) inputTitle() error { + p.LogInfo("输入文章标题...") + + titleSelectors := []string{ + ".WriteIndex-titleInput textarea", + "textarea[placeholder*='标题']", + ".Input[placeholder*='标题']", + ".title-input textarea", + } + + for _, selector := range titleSelectors { + titleInput, err := p.WaitForElementVisible(selector, 5) + if err == nil && titleInput != nil { + p.LogInfo(fmt.Sprintf("找到标题输入框: %s", selector)) + + p.ClearInput(titleInput) + p.SleepMs(300) + titleInput.Input("") + p.SleepMs(300) + titleInput.Input(p.Title) + + p.LogInfo(fmt.Sprintf("标题已输入: %s", p.Title)) + + titleInput.Evaluate(&rod.EvalOptions{ + JS: `(el) => { + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + el.dispatchEvent(new Event('blur', {bubbles: true})); + }`, + }) + p.SleepMs(500) + return nil + } + } + + return fmt.Errorf("未找到标题输入框") +} + +func (p *ZhihuPublisher) importDocument() error { + if p.SourcePath == "" { + p.LogInfo("未提供文档路径或文档不存在") + return fmt.Errorf("文档不存在") + } + + p.LogInfo(fmt.Sprintf("尝试导入文档: %s", p.SourcePath)) + + // 步骤1: 查找并点击"导入"按钮 + p.LogInfo("步骤1: 查找并点击'导入'按钮...") + + importBtn, err := p.Page.Element("button[aria-label='导入']") + if err != nil { + buttons, _ := p.Page.Elements("button") + for _, btn := range buttons { + text, _ := btn.Text() + if text == "导入" { + importBtn = btn + p.LogInfo("通过文本找到导入按钮") + break + } + } + } + + if importBtn == nil { + return fmt.Errorf("未找到导入按钮") + } + + importBtn.Click(proto.InputMouseButtonLeft, 1) + p.LogInfo("已点击导入按钮") + p.SleepMs(1000) + + // 步骤2: 查找并点击"导入文档"按钮 + p.LogInfo("步骤2: 查找并点击'导入文档'按钮...") + + docImportBtn, err := p.Page.Element("button[aria-label='导入文档']") + if err != nil { + menuButtons, _ := p.Page.Elements(".Menu button, .Popover-content button") + for _, btn := range menuButtons { + text, _ := btn.Text() + if strings.Contains(text, "导入文档") { + docImportBtn = btn + p.LogInfo("通过文本找到导入文档按钮") + break + } + } + } + + if docImportBtn == nil { + return fmt.Errorf("未找到导入文档按钮") + } + + docImportBtn.Click(proto.InputMouseButtonLeft, 1) + p.LogInfo("已点击导入文档按钮") + p.SleepMs(1000) + + // 步骤3: 查找file input并上传文档 + p.LogInfo("步骤3: 查找文件上传输入框...") + + var fileInput *rod.Element + for i := 0; i < 10; i++ { + fileInput, _ = p.Page.Element("input[type='file'][accept='.docx,.markdown,.mdown,.mkdn,.md']") + if fileInput != nil { + p.LogInfo("找到导入文档输入框") + break + } + if fileInput == nil { + hiddenInputs, _ := p.Page.Elements("input[type='file'][style*='display: none']") + for _, inp := range hiddenInputs { + accept, _ := inp.Attribute("accept") + if accept != nil && (strings.Contains(*accept, ".docx") || strings.Contains(*accept, ".md")) { + fileInput = inp + p.LogInfo("找到隐藏的导入文档输入框") + break + } + } + } + if fileInput != nil { + break + } + p.SleepMs(500) + } + + if fileInput == nil { + return fmt.Errorf("未找到文件上传输入框") + } + + p.LogInfo(fmt.Sprintf("开始上传文档: %s", p.SourcePath)) + fileInput.SetFiles([]string{p.SourcePath}) + p.LogInfo(fmt.Sprintf("文档已上传: %s", p.SourcePath)) + p.Sleep(3) + + // 等待导入完成 + success := false + for i := 0; i < 60; i++ { + p.SleepMs(1000) + + toasts, _ := p.Page.Elements(".Toast-module_toast, .el-message--success, .toast-success") + for _, toast := range toasts { + visible, _ := toast.Visible() + if visible { + text, _ := toast.Text() + if strings.Contains(text, "成功") || strings.Contains(text, "导入") || strings.Contains(text, "完成") { + p.LogInfo(fmt.Sprintf("导入成功提示: %s", text)) + success = true + break + } + } + } + if success { + break + } + + editors, _ := p.Page.Elements("[contenteditable='true'], .DraftEditor-root, .ProseMirror") + for _, editor := range editors { + text, _ := editor.Text() + if len(text) > 10 { + p.LogInfo(fmt.Sprintf("文档导入成功,内容长度: %d", len(text))) + success = true + break + } + } + if success { + break + } + } + + if success { + p.SleepMs(2000) + p.LogInfo("文档导入完成") + return nil + } + + p.LogInfo("文档导入状态未知") + return nil +} + +func (p *ZhihuPublisher) clickPublish() error { + p.LogInfo("点击发布按钮...") + + publishSelectors := []string{ + ".Button--primary:contains('发布')", + ".css-d0uhtl", + "button:contains('发布')", + ".PublishButton", + "[class*='publish'] button.Button--primary", + } + + var publishBtn *rod.Element + for _, selector := range publishSelectors { + publishBtn, _ = p.WaitForElementClickable(selector, 5) + if publishBtn != nil { + visible, _ := publishBtn.Visible() + if visible { + p.LogInfo(fmt.Sprintf("找到发布按钮: %s", selector)) + break + } + } + } + + if publishBtn == nil { + buttons, _ := p.Page.Elements("button") + for _, btn := range buttons { + visible, _ := btn.Visible() + if visible { + text, _ := btn.Text() + if text == "发布" || strings.Contains(text, "发布") { + publishBtn = btn + p.LogInfo("通过遍历按钮找到发布按钮") + break + } + } + } + } + + if publishBtn == nil { + return fmt.Errorf("未找到发布按钮") + } + + p.SleepMs(500) + + if err := publishBtn.Click(proto.InputMouseButtonLeft, 1); err != nil { + if err := p.JSClick(publishBtn); err != nil { + return fmt.Errorf("点击发布按钮失败: %v", err) + } + } + + p.LogInfo("已点击发布按钮") + p.Sleep(3) + return nil +} + +func (p *ZhihuPublisher) waitForPublishResult(timeout int) (bool, string) { + p.LogInfo("等待发布结果...") + + startTime := time.Now() + for time.Since(startTime) < time.Duration(timeout)*time.Second { + currentURL := p.GetCurrentURL() + + // 检查失败弹窗 + failedDiv, err := p.Page.Element(".Notification-textSection") + if err == nil && failedDiv != nil { + visible, _ := failedDiv.Visible() + if visible { + failedReason, _ := failedDiv.Text() + p.LogInfo(fmt.Sprintf("发布失败: %s", failedReason)) + return false, failedReason + } + } + + successKeywords := []string{"/p/", "/article/", "/people/", "/column/"} + for _, keyword := range successKeywords { + if strings.Contains(currentURL, keyword) && !strings.Contains(currentURL, "write") { + p.LogInfo(fmt.Sprintf("发布成功!URL: %s", currentURL)) + return true, "发布成功" + } + } + + p.SleepMs(1000) + } + + return false, "发布结果未知(超时)" +} + +func (p *ZhihuPublisher) PublishNote() (bool, string) { + p.StartNote() + + if err := p.SetupDriver(); err != nil { + return false, fmt.Sprintf("浏览器启动失败: %v", err) + } + defer p.Close() + + steps := []struct { + name string + fn func() error + }{ + {"初始化页面", p.InitPage}, + {"输入标题", p.inputTitle}, + {"导入内容", p.importDocument}, + {"点击发布", p.clickPublish}, + } + + for _, step := range steps { + if err := step.fn(); err != nil { + p.LogStep(step.name, false, err.Error()) + return false, fmt.Sprintf("%s失败: %v", step.name, err) + } + p.LogStep(step.name, true, "") + } + + success, message := p.waitForPublishResult(60) + if success { + p.LogInfo(fmt.Sprintf("🎉 发布成功!%s", message)) + return true, message + } + p.LogError(fmt.Sprintf("发布失败: %s", message)) + return false, message +} diff --git a/pkg/func.go b/pkg/func.go index fd5b26a..be3de7f 100644 --- a/pkg/func.go +++ b/pkg/func.go @@ -176,18 +176,55 @@ func CopyNonNilFields(src, dst interface{}) error { func DownloadFile(url string, saveDir string, filename string) (string, error) { os.MkdirAll(saveDir, 0755) - if filename == "" { - filename = uuid.New().String() + ".docx" - } - - filePath := filepath.Join(saveDir, filename) - resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() + // 获取文件扩展名 + ext := "" + + // 1. 从URL获取扩展名 + if idx := strings.LastIndex(url, "."); idx != -1 { + ext = url[idx:] + // 去除查询参数 + if idx2 := strings.Index(ext, "?"); idx2 != -1 { + ext = ext[:idx2] + } + } + + // 2. 如果URL没有扩展名,从Content-Type获取 + if ext == "" { + contentType := resp.Header.Get("Content-Type") + switch contentType { + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + ext = ".docx" + case "application/msword": + ext = ".doc" + case "application/pdf": + ext = ".pdf" + case "video/mp4": + ext = ".mp4" + case "video/x-msvideo": + ext = ".avi" + default: + // 默认空,后续会报错 + } + } + + if ext == "" { + return "", fmt.Errorf("无法确定文件类型: %s", url) + } + + if filename == "" { + filename = uuid.New().String() + ext + } else if !strings.HasSuffix(filename, ext) { + filename = filename + ext + } + + filePath := filepath.Join(saveDir, filename) + out, err := os.Create(filePath) if err != nil { return "", err