geoGo/internal/collect/doubao.go

540 lines
15 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 collect
import (
"context"
"fmt"
"geo/internal/config"
"strings"
"time"
"github.com/atotto/clipboard"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/gofiber/fiber/v2/log"
)
// DoubaoCollector 豆包收集器
type DoubaoCollector struct {
*BaseCollector
}
// NewDoubaoCollector 创建豆包收集器
func NewDoubaoCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger) CollectorInterface {
collector := &DoubaoCollector{
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
}
// 设置豆包的URL
collector.LoginURL = "https://www.doubao.com/"
collector.ChatURL = "https://www.doubao.com/chat/"
return collector
}
// CheckLoginStatus 检查登录状态
func (c *DoubaoCollector) CheckLoginStatus() bool {
c.LogInfo("检查豆包登录状态...")
// 方法3: 检查是否有登录按钮(如果存在说明未登录)
loginButtons, err := c.Page.Elements("button")
if err == nil {
for _, btn := range loginButtons {
text, _ := btn.Text()
trimmedText := strings.TrimSpace(text)
if trimmedText == "登录" || trimmedText == "Login" || strings.Contains(trimmedText, "登录") {
c.LogInfo(fmt.Sprintf("检测到登录按钮'%s',说明未登录", trimmedText))
return false
}
}
}
c.LogInfo("未检测到登录状态相关元素")
return true
}
// WaitLogin 等待登录
func (c *DoubaoCollector) WaitLogin() (bool, string) {
if err := c.SetupDriver(); err != nil {
return false, fmt.Sprintf("浏览器启动失败: %v", err)
}
defer c.Close()
c.LogInfo("导航到豆包页面...")
c.Page.MustNavigate(c.ChatURL)
c.Sleep(3)
// 截图查看初始状态
c.Screenshot("doubao_initial")
if c.CheckLoginStatus() {
c.LogInfo("已登录保存cookies")
c.SaveCookies()
return true, "already_logged_in"
}
c.LogInfo("未登录,等待手动登录...")
c.Screenshot("doubao_need_login")
// 最多等待300秒
for i := 0; i < 300; i++ {
if c.CheckLoginStatus() {
c.Sleep(2)
c.SaveCookies()
c.Screenshot("doubao_login_success")
c.LogInfo("登录成功!")
return true, "login_success"
}
// 每10秒输出一次提示
if i%10 == 0 && i > 0 {
c.LogInfo(fmt.Sprintf("等待登录中... 已等待 %d 秒", i))
}
time.Sleep(1 * time.Second)
}
return false, "登录超时"
}
// AskQuestion 提问并获取答案
func (c *DoubaoCollector) AskQuestion(question string) (*CollectResult, error) {
if err := c.SetupDriver(); err != nil {
return nil, fmt.Errorf("浏览器启动失败: %v", err)
}
defer c.Close()
if err := c.InitPage(); err != nil {
return nil, fmt.Errorf("页面初始化失败: %v", err)
}
// 检查是否登录
if !c.CheckLoginStatus() {
return nil, fmt.Errorf("未登录请先调用WaitLogin进行登录")
}
c.LogInfo(fmt.Sprintf("开始提问: %s", question))
if err := c.inputQuestion(question); err != nil {
return nil, fmt.Errorf("输入问题失败: %v", err)
}
if err := c.clickSendButton(); err != nil {
return nil, fmt.Errorf("点击发送按钮失败: %v", err)
}
answer, err := c.waitForAnswer()
if err != nil {
return nil, fmt.Errorf("获取答案失败: %v", err)
}
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
// 获取分享链接
shareLink := c.getShareLink()
c.LogInfo(fmt.Sprintf("✓ 获取答案成功,长度: %d 字符", len(answer)))
return &CollectResult{
Answer: answerStr,
ShareLink: shareLink,
IsExposure: isExposure,
}, nil
}
// inputQuestion 输入问题
func (c *DoubaoCollector) inputQuestion(question string) error {
c.LogInfo("输入问题...")
// 豆包的输入框选择器 - 使用精确的class匹配
inputSelectors := []string{
"textarea[placeholder*='发消息...']",
"[class*='input'] textarea",
"textarea.semi-input-textarea",
"textarea[placeholder='发消息...']",
"textarea[class*='semi-input-textarea']",
}
var inputBox *rod.Element
var err error
for _, selector := range inputSelectors {
inputBox, err = c.WaitForElementVisible(selector, 10)
if err == nil && inputBox != nil {
c.LogInfo(fmt.Sprintf("找到输入框: %s", selector))
break
}
}
if inputBox == nil {
return fmt.Errorf("未找到输入框")
}
// 点击获取焦点
if err := inputBox.Click(proto.InputMouseButtonLeft, 1); err != nil {
return fmt.Errorf("点击输入框失败: %v", err)
}
// 清空输入框(如果失败也继续)
if err := c.ClearInput(inputBox); err != nil {
c.LogInfo(fmt.Sprintf("清空输入框失败: %v", err))
}
// 使用原生Input方法输入更稳定
inputBox.Input(question)
c.LogInfo(fmt.Sprintf("问题已输入: %s", question))
return nil
}
// clickSendButton 点击发送按钮
func (c *DoubaoCollector) clickSendButton() error {
c.LogInfo("点击发送按钮...")
// 尝试多种方式查找发送按钮
sendSelectors := []string{
"button[class*='send']",
"button[class*='submit']",
".send-btn",
".submit-btn",
"[aria-label*='发送']",
"[aria-label*='send']",
".send-icon",
"button svg[path*='send']",
"button svg[path*='arrow']",
}
var sendBtn *rod.Element
var err error
// 先尝试通过选择器查找
for _, selector := range sendSelectors {
sendBtn, err = c.WaitForElementClickable(selector, 5)
if err == nil && sendBtn != nil {
c.LogInfo(fmt.Sprintf("找到发送按钮: %s", selector))
break
}
}
// 如果没找到尝试遍历所有button元素
if sendBtn == nil {
c.LogInfo("通过选择器未找到发送按钮尝试遍历所有button元素...")
allButtons, _ := c.Page.Elements("button")
for _, btn := range allButtons {
// 检查按钮是否可点击且可见
visible, _ := btn.Visible()
if visible {
classAttr, _ := btn.Attribute("class")
text, _ := btn.Text()
// 检查是否包含send、submit等关键词
if classAttr != nil && (strings.Contains(strings.ToLower(*classAttr), "send") ||
strings.Contains(strings.ToLower(*classAttr), "submit")) {
sendBtn = btn
c.LogInfo(fmt.Sprintf("通过class找到发送按钮: class=%s", *classAttr))
break
}
// 检查文本内容
trimmedText := strings.TrimSpace(text)
if trimmedText == "发送" || trimmedText == "Send" {
sendBtn = btn
c.LogInfo(fmt.Sprintf("通过文本找到发送按钮: text=%s", trimmedText))
break
}
}
}
}
// 最后的fallback查找最后一个button
if sendBtn == nil {
buttons, _ := c.Page.Elements("button")
if len(buttons) > 0 {
sendBtn = buttons[len(buttons)-1]
c.LogInfo("使用最后一个button作为发送按钮")
}
}
if sendBtn == nil {
return fmt.Errorf("未找到发送按钮")
}
// 滚动到可见区域
if err := sendBtn.ScrollIntoView(); err != nil {
c.LogInfo(fmt.Sprintf("滚动失败: %v", err))
}
// 点击发送按钮
c.LogInfo("执行点击...")
if err := sendBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
return fmt.Errorf("点击发送按钮失败: %v", err)
}
c.LogInfo("已点击发送按钮")
return nil
}
// waitForAnswer 等待并获取答案(处理流式输出)
func (c *DoubaoCollector) waitForAnswer() (string, error) {
c.LogInfo("等待AI回答...")
timeout := 180 // 最大等待时间(秒)
startTime := time.Now()
var lastAnswer string
var stableCount int // 稳定计数器
const requiredStableCount = 5 // 需要连续5次内容不变才认为完成
isAnswering := false // 标记是否正在回答中
for time.Since(startTime).Seconds() < float64(timeout) {
// 尝试多种方式查找答案容器
answerSelectors := []string{
"div[data-message-id]",
"div[data-message-id*='']",
}
var answerText string
for _, selector := range answerSelectors {
answerElements, err := c.Page.Elements(selector)
if err == nil && len(answerElements) > 0 {
// 取最后一个元素(最新的回答)
lastAnswerElem := answerElements[len(answerElements)-1]
visible, _ := lastAnswerElem.Visible()
if visible {
// 尝试获取HTML内容
htmlContent, err := lastAnswerElem.HTML()
if err == nil && len(strings.TrimSpace(htmlContent)) > 30 {
// 清理HTML标签只保留纯文本
answerText = CleanHTMLTags(htmlContent)
c.LogInfo(fmt.Sprintf("找到答案容器: %s, 清理后文本长度: %d", selector, len(answerText)))
break
}
// 如果HTML获取失败尝试获取文本
text, err := lastAnswerElem.Text()
if err == nil && len(strings.TrimSpace(text)) > 30 {
answerText = strings.TrimSpace(text)
c.LogInfo(fmt.Sprintf("找到答案容器: %s, 文本长度: %d", selector, len(answerText)))
break
}
}
}
}
// 如果常规方法没找到尝试查找所有包含较多文本的div
if answerText == "" {
allDivs, _ := c.Page.Elements("div")
for _, div := range allDivs {
visible, _ := div.Visible()
if !visible {
continue
}
text, err := div.Text()
if err == nil {
trimmedText := strings.TrimSpace(text)
// 查找包含较多文本且不是输入框的div
if len(trimmedText) > 50 && len(trimmedText) < 5000 {
// 排除输入框相关的div
classAttr, _ := div.Attribute("class")
if classAttr != nil {
classLower := strings.ToLower(*classAttr)
if strings.Contains(classLower, "input") ||
strings.Contains(classLower, "textarea") ||
strings.Contains(classLower, "send") {
continue
}
}
answerText = CleanHTMLTags(trimmedText)
c.LogInfo(fmt.Sprintf("通过遍历div找到答案文本长度: %d", len(answerText)))
break
}
}
}
}
// 检查是否获取到答案
if answerText != "" && len(answerText) > 30 {
if !isAnswering {
c.LogInfo("检测到AI开始回答...")
isAnswering = true
}
// 检查内容是否稳定(流式输出完成)
if answerText == lastAnswer {
stableCount++
c.LogInfo(fmt.Sprintf("答案稳定中... (%d/%d), 长度: %d", stableCount, requiredStableCount, len(answerText)))
// 如果内容稳定足够次数,说明回答完成
if stableCount >= requiredStableCount {
c.LogInfo(fmt.Sprintf("✓ AI回答完成最终长度: %d 字符", len(answerText)))
return answerText, nil
}
} else {
// 内容还在变化,重置计数器
stableCount = 0
lastAnswer = answerText
c.LogInfo(fmt.Sprintf("检测到流式输出,当前长度: %d 字符", len(answerText)))
}
}
c.SleepMs(1500) // 每1.5秒检查一次
// 每10秒输出一次等待状态
elapsed := int(time.Since(startTime).Seconds())
if elapsed > 0 && elapsed%10 == 0 {
c.LogInfo(fmt.Sprintf("等待AI回答中... 已等待 %d 秒", elapsed))
// 截图帮助调试
if elapsed%30 == 0 {
c.Screenshot(fmt.Sprintf("doubao_wait_answer_%d", elapsed))
}
}
}
return "", fmt.Errorf("等待答案超时(%d秒", timeout)
}
// getShareLink 尝试获取当前对话的分享链接
func (c *DoubaoCollector) getShareLink() string {
c.LogInfo("尝试获取分享链接...")
// 步骤1: 找到class包含message-action-button-main的div
actionDiv, err := c.Page.Element("div[data-foundation-type*='receive-message-action-bar']")
if err != nil || actionDiv == nil {
c.LogInfo("未找到message-action-button-main元素")
return ""
}
c.LogInfo("找到message-action-button-main元素")
// 步骤2: 在该div中找到所有button取倒数第二个作为分享按钮
buttons, err := actionDiv.Elements("button")
if err != nil || len(buttons) == 0 {
c.LogInfo("未找到button元素")
return ""
}
if len(buttons) < 2 {
c.LogInfo(fmt.Sprintf("button数量不足(%d),无法获取倒数第二个", len(buttons)))
return ""
}
shareBtn := buttons[len(buttons)-3]
c.LogInfo(fmt.Sprintf("找到分享按钮(共%d个button", len(buttons)))
// 检查是否可点击如果pointer-events为none使用JavaScript点击
visible, _ := shareBtn.Visible()
if !visible {
c.LogInfo("分享按钮不可见尝试使用JavaScript点击")
// 使用立即执行函数,但返回一个空函数避免.apply错误
_, err := c.Page.Eval(`(function(){Array.from(document.querySelectorAll('div[class*="message-action-button-main"] button')).slice(-2)[0].click();return function(){};})`)
if err != nil {
c.LogInfo(fmt.Sprintf("JavaScript点击失败: %v", err))
return ""
}
} else {
// 正常点击
if err := shareBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
c.LogInfo(fmt.Sprintf("点击分享按钮失败: %v尝试JavaScript点击", err))
// Fallback: 使用JavaScript点击
_, err := c.Page.Eval(`(function(){Array.from(document.querySelectorAll('div[class*="message-action-button-main"] button')).slice(-2)[0].click();return function(){};})`)
if err != nil {
c.LogInfo(fmt.Sprintf("JavaScript点击也失败: %v", err))
return ""
}
}
}
c.SleepMs(500)
// 步骤3: 找到内容为"复制链接"的span并点击
copySpan, err := c.Page.ElementX("//span[contains(text(), '复制链接')]")
if err != nil || copySpan == nil {
c.LogInfo("未找到'复制链接'span元素")
return ""
}
c.LogInfo("找到'复制链接'span元素点击复制...")
if err := copySpan.Click(proto.InputMouseButtonLeft, 1); err != nil {
c.LogInfo(fmt.Sprintf("点击复制按钮失败: %v尝试JavaScript点击", err))
// Fallback: 使用JavaScript点击
script := `
(function() {
var spans = document.querySelectorAll('span');
for (var i = 0; i < spans.length; i++) {
if (spans[i].textContent.includes('复制链接')) {
spans[i].click();
return true;
}
}
return false;
})()
`
result, err := c.Page.Eval(script)
if err != nil || result == nil {
c.LogInfo(fmt.Sprintf("JavaScript点击复制按钮失败: %v", err))
return ""
}
}
c.SleepMs(500)
// 步骤4: 从剪贴板获取内容
clipboardContent := c.getClipboardContent()
if clipboardContent != "" {
c.LogInfo(fmt.Sprintf("从剪贴板获取到分享链接: %s", clipboardContent))
return clipboardContent
}
c.LogInfo("未能从剪贴板获取链接")
return ""
}
// getClipboardContent 从剪贴板获取内容
func (c *DoubaoCollector) getClipboardContent() string {
// 使用atotto/clipboard库读取系统剪贴板
text, err := clipboard.ReadAll()
if err != nil {
c.LogInfo(fmt.Sprintf("读取剪贴板失败: %v", err))
return ""
}
if text == "" {
c.LogInfo("剪贴板内容为空")
return ""
}
c.LogInfo(fmt.Sprintf("剪贴板原始内容: %s", text))
return text
}
// extractURL 从文本中提取 URL
func extractURL(text string) string {
// 简单的 URL 提取逻辑
start := strings.Index(text, "https://")
if start == -1 {
start = strings.Index(text, "http://")
}
if start != -1 {
end := strings.Index(text[start:], " ")
if end == -1 {
return text[start:]
}
return text[start : start+end]
}
return ""
}
// SafeElement 安全地获取元素
func (c *DoubaoCollector) SafeElement(selector string) (*rod.Element, error) {
exists, _, err := c.Page.Has(selector)
if err != nil {
return nil, err
}
if !exists {
return nil, nil
}
return c.Page.Element(selector)
}