This commit is contained in:
Rzy 2026-04-25 23:55:30 +08:00
parent 1f79fa82ee
commit 0647790cc2
8 changed files with 657 additions and 72 deletions

169
doubao_test.go Normal file
View File

@ -0,0 +1,169 @@
package collect
import (
"context"
"geo/internal/collect"
"geo/internal/config"
"testing"
"time"
"github.com/gofiber/fiber/v2/log"
)
var (
doubaoCfg, _ = config.LoadConfig()
doubaoManager = collect.NewCollectManager(context.Background(), doubaoCfg, log.DefaultLogger())
)
// TestDoubaoCollector_WaitLogin 测试豆包登录功能
func TestDoubaoCollector_WaitLogin(t *testing.T) {
if testing.Short() {
t.Skip("跳过需要浏览器交互的测试")
}
params := &collect.CollectParams{
Headless: false, // 显示浏览器窗口以便扫码登录
RequestID: "test_doubao_login_001",
Platform: "doubao",
}
t.Log("开始测试豆包登录...")
t.Log("请在打开的浏览器窗口中完成豆包账号登录(扫码或输入账号密码)")
success, msg := doubaoManager.WaitLogin("doubao", params)
if !success {
t.Errorf("豆包登录失败: %s", msg)
return
}
t.Logf("豆包登录成功: %s", msg)
t.Log("Cookie已保存后续测试可以使用已登录状态")
}
// TestDoubaoCollector_AskQuestion 测试豆包提问功能
// 注意:此测试需要有效的登录状态
func TestDoubaoCollector_AskQuestion(t *testing.T) {
if testing.Short() {
t.Skip("跳过需要浏览器交互的测试")
}
// 设置收集参数
params := &collect.CollectParams{
Headless: false, // 显示浏览器以便调试
RequestID: "test_doubao_001",
Platform: "doubao",
}
// 定义提问内容
question := "四川房地产软件排名"
t.Logf("向豆包提问: %s", question)
// 调用管理器提问并获取答案
result, err := doubaoManager.AskQuestion("doubao", params, question)
if err != nil {
t.Errorf("提问失败: %v", err)
return
}
t.Logf("获取到答案:\n%s", result.Answer)
// 验证答案非空
if len(result.Answer) == 0 {
t.Error("答案为空")
}
}
// TestDoubaoCollector_MultipleQuestions 测试豆包多次提问
func TestDoubaoCollector_MultipleQuestions(t *testing.T) {
if testing.Short() {
t.Skip("跳过需要浏览器交互的测试")
}
params := &collect.CollectParams{
Headless: false,
RequestID: "test_doubao_multi_001",
Platform: "doubao",
}
questions := []string{
"什么是人工智能?",
"Python和Go的区别是什么",
"如何学习编程?",
"中国有哪些著名的旅游景点?",
"健康饮食的基本原则是什么?",
"区块链技术的主要应用场景有哪些?",
"如何提高英语口语能力?",
"云计算的优势和劣势分别是什么?",
"环境保护的重要性体现在哪些方面?",
"未来十年最有前景的行业有哪些?",
}
for i, question := range questions {
t.Logf("========== 第 %d/%d 个问题 ==========", i+1, len(questions))
t.Logf("问题: %s", question)
result, err := doubaoManager.AskQuestion("doubao", params, question)
if err != nil {
t.Errorf("第 %d 个问题提问失败: %v", i+1, err)
continue
}
t.Logf("✓ 第 %d 个问题的答案长度: %d 字符", i+1, len(result.Answer))
t.Logf("答案预览: %s...", result.Answer[:min(100, len(result.Answer))])
// 每个问题之间等待一下
if i < len(questions)-1 {
t.Log("等待3秒后继续下一个问题...")
time.Sleep(3 * time.Second)
}
}
t.Logf("========== 测试完成 ==========")
t.Logf("成功回答了 %d 个问题", len(questions))
}
// TestDoubaoCollector_SpeedTest 测试优化后的速度
func TestDoubaoCollector_SpeedTest(t *testing.T) {
if testing.Short() {
t.Skip("跳过需要浏览器交互的测试")
}
params := &collect.CollectParams{
Headless: false,
RequestID: "test_doubao_speed",
Platform: "doubao",
}
question := "1+1等于几"
startTime := time.Now()
t.Logf("开始提问: %s", question)
result, err := doubaoManager.AskQuestion("doubao", params, question)
if err != nil {
t.Fatalf("提问失败: %v", err)
}
elapsed := time.Since(startTime)
t.Logf("✓ 完成时间: %v", elapsed)
t.Logf("答案长度: %d 字符", len(result.Answer))
t.Logf("分享链接: %s", result.ShareLink)
t.Logf("答案预览: %s...", result.Answer[:min(100, len(result.Answer))])
// 如果超过60秒说明还有优化空间
if elapsed > 60*time.Second {
t.Logf("⚠️ 警告: 耗时较长 (%v),可能需要进一步优化", elapsed)
} else {
t.Logf("✅ 速度正常")
}
}
// min 辅助函数
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@ -21,6 +21,7 @@ type BaseCollector struct {
Headless bool Headless bool
RequestID string RequestID string
Platform string Platform string
KeyWords []string
Browser *rod.Browser Browser *rod.Browser
Page *rod.Page Page *rod.Page
@ -52,6 +53,7 @@ func NewBaseCollector(ctx context.Context, params *CollectParams, config *config
Headless: params.Headless, Headless: params.Headless,
RequestID: params.RequestID, RequestID: params.RequestID,
Platform: params.Platform, Platform: params.Platform,
KeyWords: params.KeyWords,
Logger: baseLogger, Logger: baseLogger,
config: config, config: config,
MaxRetries: 3, MaxRetries: 3,

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/atotto/clipboard"
"github.com/go-rod/rod" "github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto" "github.com/go-rod/rod/lib/proto"
"github.com/gofiber/fiber/v2/log" "github.com/gofiber/fiber/v2/log"
@ -32,24 +33,23 @@ func NewDoubaoCollector(ctx context.Context, params *CollectParams, cfg *config.
// CheckLoginStatus 检查登录状态 // CheckLoginStatus 检查登录状态
func (c *DoubaoCollector) CheckLoginStatus() bool { func (c *DoubaoCollector) CheckLoginStatus() bool {
currentURL := c.GetCurrentURL() c.LogInfo("检查豆包登录状态...")
// 检查是否在聊天页面
if strings.Contains(currentURL, "doubao.com") {
// 查找用户信息元素
userInfo, err := c.SafeElement(".user-info, .avatar, [class*='user-profile']")
if err == nil && userInfo != nil {
return true
}
// 检查是否有输入框
inputBox, err := c.SafeElement("textarea, [contenteditable='true']")
if err == nil && inputBox != nil {
return true
}
}
// 方法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 return false
}
}
}
c.LogInfo("未检测到登录状态相关元素")
return true
} }
// WaitLogin 等待登录 // WaitLogin 等待登录
@ -59,20 +59,37 @@ func (c *DoubaoCollector) WaitLogin() (bool, string) {
} }
defer c.Close() defer c.Close()
c.Page.MustNavigate(c.LoginURL) c.LogInfo("导航到豆包页面...")
c.Page.MustNavigate(c.ChatURL)
c.Sleep(3) c.Sleep(3)
// 截图查看初始状态
c.Screenshot("doubao_initial")
if c.CheckLoginStatus() { if c.CheckLoginStatus() {
c.LogInfo("已登录保存cookies")
c.SaveCookies() c.SaveCookies()
return true, "already_logged_in" return true, "already_logged_in"
} }
c.LogInfo("未登录,等待手动登录...")
c.Screenshot("doubao_need_login")
// 最多等待300秒
for i := 0; i < 300; i++ { for i := 0; i < 300; i++ {
if c.CheckLoginStatus() { if c.CheckLoginStatus() {
c.Sleep(2) c.Sleep(2)
c.SaveCookies() c.SaveCookies()
c.Screenshot("doubao_login_success")
c.LogInfo("登录成功!")
return true, "login_success" return true, "login_success"
} }
// 每10秒输出一次提示
if i%10 == 0 && i > 0 {
c.LogInfo(fmt.Sprintf("等待登录中... 已等待 %d 秒", i))
}
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
} }
@ -90,7 +107,12 @@ func (c *DoubaoCollector) AskQuestion(question string) (*CollectResult, error) {
return nil, fmt.Errorf("页面初始化失败: %v", err) return nil, fmt.Errorf("页面初始化失败: %v", err)
} }
c.Sleep(3) // 检查是否登录
if !c.CheckLoginStatus() {
return nil, fmt.Errorf("未登录请先调用WaitLogin进行登录")
}
c.LogInfo(fmt.Sprintf("开始提问: %s", question))
if err := c.inputQuestion(question); err != nil { if err := c.inputQuestion(question); err != nil {
return nil, fmt.Errorf("输入问题失败: %v", err) return nil, fmt.Errorf("输入问题失败: %v", err)
@ -104,23 +126,30 @@ func (c *DoubaoCollector) AskQuestion(question string) (*CollectResult, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("获取答案失败: %v", err) return nil, fmt.Errorf("获取答案失败: %v", err)
} }
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
// 获取分享链接
shareLink := c.getShareLink()
c.LogInfo(fmt.Sprintf("✓ 获取答案成功,长度: %d 字符", len(answer)))
return &CollectResult{ return &CollectResult{
Answer: answer, Answer: answerStr,
ShareLink: "", ShareLink: shareLink,
IsExposure: isExposure,
}, nil }, nil
} }
// inputQuestion 输入问题 // inputQuestion 输入问题
func (c *DoubaoCollector) inputQuestion(question string) error { func (c *DoubaoCollector) inputQuestion(question string) error {
c.LogInfo("输入问题...")
// 豆包的输入框选择器 - 使用精确的class匹配
inputSelectors := []string{ inputSelectors := []string{
"textarea[placeholder*='输入']", "textarea[placeholder*='发消息...']",
"textarea[placeholder*='问']", "[class*='input'] textarea",
"textarea", "textarea.semi-input-textarea",
"[contenteditable='true']", "textarea[placeholder='发消息...']",
".chat-input textarea", "textarea[class*='semi-input-textarea']",
"#input-box",
".input-area textarea",
} }
var inputBox *rod.Element var inputBox *rod.Element
@ -129,6 +158,7 @@ func (c *DoubaoCollector) inputQuestion(question string) error {
for _, selector := range inputSelectors { for _, selector := range inputSelectors {
inputBox, err = c.WaitForElementVisible(selector, 10) inputBox, err = c.WaitForElementVisible(selector, 10)
if err == nil && inputBox != nil { if err == nil && inputBox != nil {
c.LogInfo(fmt.Sprintf("找到输入框: %s", selector))
break break
} }
} }
@ -137,110 +167,382 @@ func (c *DoubaoCollector) inputQuestion(question string) error {
return fmt.Errorf("未找到输入框") return fmt.Errorf("未找到输入框")
} }
// 点击获取焦点
if err := inputBox.Click(proto.InputMouseButtonLeft, 1); err != nil { if err := inputBox.Click(proto.InputMouseButtonLeft, 1); err != nil {
return fmt.Errorf("点击输入框失败: %v", err) return fmt.Errorf("点击输入框失败: %v", err)
} }
c.SleepMs(500)
// 清空输入框(如果失败也继续)
if err := c.ClearInput(inputBox); err != nil { if err := c.ClearInput(inputBox); err != nil {
// Ignore clear error c.LogInfo(fmt.Sprintf("清空输入框失败: %v", err))
} }
c.SleepMs(300)
if err := c.SetInputValue(inputBox, question); err != nil { // 使用原生Input方法输入更稳定
inputBox.Input(question) inputBox.Input(question)
} c.LogInfo(fmt.Sprintf("问题已输入: %s", question))
c.SleepMs(1000)
return nil return nil
} }
// clickSendButton 点击发送按钮 // clickSendButton 点击发送按钮
func (c *DoubaoCollector) clickSendButton() error { func (c *DoubaoCollector) clickSendButton() error {
c.LogInfo("点击发送按钮...")
// 尝试多种方式查找发送按钮
sendSelectors := []string{ sendSelectors := []string{
"button[class*='send']", "button[class*='send']",
"button[class*='submit']", "button[class*='submit']",
".send-btn", ".send-btn",
".submit-btn", ".submit-btn",
"button svg[path*='send']",
"[aria-label*='发送']", "[aria-label*='发送']",
"[aria-label*='send']",
".send-icon", ".send-icon",
"button svg[path*='send']",
"button svg[path*='arrow']",
} }
var sendBtn *rod.Element var sendBtn *rod.Element
var err error var err error
// 先尝试通过选择器查找
for _, selector := range sendSelectors { for _, selector := range sendSelectors {
sendBtn, err = c.WaitForElementClickable(selector, 5) sendBtn, err = c.WaitForElementClickable(selector, 5)
if err == nil && sendBtn != nil { if err == nil && sendBtn != nil {
c.LogInfo(fmt.Sprintf("找到发送按钮: %s", selector))
break 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 { if sendBtn == nil {
sendBtn, err = c.Page.Element("button svg")
if err != nil {
return fmt.Errorf("未找到发送按钮") 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{
"[class*='message-content']",
"[class*='response-text']",
"[class*='assistant'] [class*='content']",
"[class*='bot'] [class*='message']",
".chat-message.bot",
".answer-box",
"[class*='answer']",
"[class*='reply']",
"[data-testid*='message']",
// 豆包特定的选择器
"[class*='bubble']",
"[class*='chat-bubble']",
"[class*='msg-content']",
"[class*='text-content']",
".markdown-body",
"[class*='markdown']",
// 更通用的选择器
"div[class*='content']",
"div[class*='text']",
"article",
"section",
}
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[class*='message-action-button-main']")
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 ""
}
// 取倒数第二个button
shareBtn := buttons[len(buttons)-3]
c.LogInfo(fmt.Sprintf("找到分享按钮倒数第2个共%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) c.SleepMs(500)
if err := c.JSClick(sendBtn); err != nil { // 步骤3: 找到内容为"复制链接"的span并点击
return fmt.Errorf("点击发送按钮失败: %v", err) copySpan, err := c.Page.ElementX("//span[contains(text(), '复制链接')]")
if err != nil || copySpan == nil {
c.LogInfo("未找到'复制链接'span元素")
return ""
} }
c.SleepMs(2000) 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)
return nil // 步骤4: 从剪贴板获取内容
clipboardContent := c.getClipboardContent()
if clipboardContent != "" {
c.LogInfo(fmt.Sprintf("从剪贴板获取到分享链接: %s", clipboardContent))
return clipboardContent
}
c.LogInfo("未能从剪贴板获取链接")
return ""
} }
// waitForAnswer 等待并获取答案 // getClipboardContent 从剪贴板获取内容
func (c *DoubaoCollector) waitForAnswer() (string, error) { func (c *DoubaoCollector) getClipboardContent() string {
timeout := 120 // 使用atotto/clipboard库读取系统剪贴板
startTime := time.Now() text, err := clipboard.ReadAll()
lastAnswerLength := 0 if err != nil {
c.LogInfo(fmt.Sprintf("读取剪贴板失败: %v", err))
for time.Since(startTime).Seconds() < float64(timeout) { return ""
answerSelectors := []string{
".message-content",
".response-text",
"[class*='assistant'] [class*='content']",
"[class*='bot'] [class*='message']",
".chat-message.bot",
".answer-box",
} }
for _, selector := range answerSelectors { if text == "" {
answerElements, err := c.Page.Elements(selector) c.LogInfo("剪贴板内容为空")
if err == nil && len(answerElements) > 0 { return ""
lastAnswer := answerElements[len(answerElements)-1]
visible, _ := lastAnswer.Visible()
if visible {
text, err := lastAnswer.Text()
if err == nil && len(strings.TrimSpace(text)) > 0 {
isGenerating := strings.Contains(text, "正在") ||
strings.Contains(text, "思考中") ||
strings.Contains(text, "typing")
if !isGenerating {
currentLength := len(text)
if currentLength == lastAnswerLength && currentLength > 10 {
return strings.TrimSpace(text), nil
}
lastAnswerLength = currentLength
}
}
}
}
} }
c.SleepMs(1500) c.LogInfo(fmt.Sprintf("剪贴板原始内容: %s", text))
} return text
}
return "", fmt.Errorf("等待答案超时") // 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 安全地获取元素 // SafeElement 安全地获取元素

View File

@ -18,7 +18,8 @@ type CollectorInterface interface {
// CollectResult 收集结果 // CollectResult 收集结果
type CollectResult struct { type CollectResult struct {
Answer string `json:"answer"` // AI回答内容 Answer string `json:"answer"` // AI回答内容
ShareLink string `json:"share_link"` // 分享链接 ShareLink string `json:"share_link"` // 分享链接\
IsExposure bool `json:"is_exposure"` // 是否曝光
} }
// NewCollector 创建收集器的工厂函数类型 // NewCollector 创建收集器的工厂函数类型
@ -40,8 +41,8 @@ type CollectorValue struct {
type CollectParams struct { type CollectParams struct {
Headless bool // 是否无头模式 Headless bool // 是否无头模式
RequestID string // 请求ID RequestID string // 请求ID
Platform string // 平台类型: wenxin, deepseek, doubao, qianwen Platform string
KeyWords []string
} }
// CollectorMap 收集器注册表 // CollectorMap 收集器注册表

View File

@ -68,9 +68,10 @@ func CleanDivTags(html string) string {
// htmlContent: 原始HTML内容 // htmlContent: 原始HTML内容
// pointKeys: 需要高亮的关键词列表 // pointKeys: 需要高亮的关键词列表
// 返回处理后的HTML内容每个关键词会被不同颜色的span标签包裹 // 返回处理后的HTML内容每个关键词会被不同颜色的span标签包裹
func HighlightKeywordsInHTML(htmlContent string, pointKeys []string) string { func HighlightKeywordsInHTML(htmlContent string, pointKeys []string) (string, bool) {
var isExposure bool
if htmlContent == "" || len(pointKeys) == 0 { if htmlContent == "" || len(pointKeys) == 0 {
return htmlContent return htmlContent, isExposure
} }
// 预定义的颜色列表使用CSS颜色值 // 预定义的颜色列表使用CSS颜色值
@ -111,12 +112,17 @@ func HighlightKeywordsInHTML(htmlContent string, pointKeys []string) string {
pattern := fmt.Sprintf(`(?i)(%s)`, escapedKeyword) pattern := fmt.Sprintf(`(?i)(%s)`, escapedKeyword)
re := regexp.MustCompile(pattern) re := regexp.MustCompile(pattern)
// 检查是否匹配到关键词
if re.MatchString(result) {
isExposure = true
}
// 替换匹配的关键词为带颜色的span标签 // 替换匹配的关键词为带颜色的span标签
replacement := fmt.Sprintf(`<span style="color:%s;font-weight:bold;">$1</span>`, color) replacement := fmt.Sprintf(`<span style="color:%s;font-weight:bold;">$1</span>`, color)
result = re.ReplaceAllString(result, replacement) result = re.ReplaceAllString(result, replacement)
} }
return result return result, isExposure
} }
// HighlightKeywordsInText 在纯文本中高亮显示指定的关键词先转换为HTML // HighlightKeywordsInText 在纯文本中高亮显示指定的关键词先转换为HTML

View File

@ -0,0 +1,103 @@
package collect
import (
"strings"
"testing"
)
// TestHighlightKeywordsInHTML 测试HTML内容关键词高亮功能
func TestHighlightKeywordsInHTML(t *testing.T) {
html := `<p>在四川房地产软件领域根据功能深度本地化服务技术实力及性价比等维度评测以下软件表现突出且排名靠前</p> <h3><strong>1. 云案场</strong></h3> <p><strong>核心优势</strong></p> <ul> <li><strong>全营销场景覆盖</strong>三大体系十五大云产品如云获客云风控云售楼支持从线上拓客到售后交房的全流程管理</li> <li><strong>渠道风控专家</strong>集成刷脸核验无感抓拍等AI能力杜绝虚假带看客户判客准确率提升至99%营销费效比降低25%</li> <li><strong>本地化服务强</strong>服务网点遍布全国25城四川本地响应速度快成功案例包括万达集团中铁二局等3000+企业</li> <li><strong>生态集成能力</strong>提供标准API接口可与阿里云用友金蝶等生态平台打通降低企业集成成本</li> </ul> <p><strong>适用场景</strong></p> <ul> <li>大型房企及多项
目开发商需集团管控数据安全与全流程覆盖</li> <li>区域龙头房企注重本地化适配与性价比</li> </ul> <h3><strong>2. 明源云客</strong></h3> <p><strong>核心优势</strong></p> <ul> <li><strong>营销风控领域领先</strong>区块链存证功能可防止渠道飞单和数据篡改适合管理严格的大型集团型房企</li> <li><strong>数据驱动决策</strong>通过大数据分析提供市场趋势客户需求等报告辅助科学决策</li> </ul> <p><strong>适用场景</strong></p> <ul> <li>对数据安全与合规性要求高的房企如涉及多项目跨区域管理</li> </ul> <h3><strong>3. 用友地产CRM / 金蝶我家云售楼版</strong></h3> <p><strong>核心优势</strong></p> <ul> <li><strong>业财一体化</strong>若房企已使用用友或金蝶的财务系统选择其地产模块可实现业务与财务数据无缝对接强化集团管控</li> <li><strong>品牌与经验</strong
>用友金蝶为国内知名ERP供应商服务经验丰富用户基础广泛</li> </ul> <p><strong>适用场景</strong></p> <ul> <li>中大型房企需财务与业务系统深度整合</li> </ul> <h3><strong>4. 元度云案场</strong></h3> <p><strong>核心优势</strong></p> <ul> <li><strong>轻量化实施</strong>实施周期短采购成本低适合追求快速上线和成本控制的中小型房企</li> <li><strong>移动化体验</strong>支持移动端办公方便销售人员随时处理业务</li> </ul> <p><strong>适用场景</strong></p> <ul> <li>预算有限需快速部署的中小型房企</li> </ul> <h3><strong>5. 贝壳找房/链家网</strong></h3> <p><strong>核心优势</strong></p> <ul> <li><strong>庞大生态与真实房源</strong>线上线下生态完善真实房源体系覆盖二手房交易与渠道带客</li> <li><strong>技术赋能</strong>VR看房智能估价等功能提升客户体验</li> </ul> <p><str
ong>适用场景</strong></p> <ul> <li>新房项目需外部渠道导流或二手房业务占比较大的房企</li> </ul> <h3><strong>排名依据与选型建议</strong></h3> <ol> <li><strong>功能深度</strong>云案场与明源云客在全流程覆盖与风控领域表现突出适合大型房企用友/金蝶强于业财一体化</li> <li><strong>本地化服务</strong>云案场在四川本地响应速度与案例经验占优</li> <li><strong>性价比</strong>元度云案场实施成本低适合中小型房企云案场提供灵活模块组合适配不同规模需求</li> <li><strong>技术实力</strong>云案场明源云客等获等保认证数据安全有保障</li> </ol> <p><strong>建议</strong></p> <ul> <li>大型房企优先选择<strong>云案场</strong><strong>明源云客</strong>强化集团管控与风控能力</li> <li>中小型房企可考虑<strong>元度云案场</strong><strong>用友/金蝶地产模块</strong>
平衡成本与功能需求</li> <li>若需外部渠道导流可补充<strong>贝壳找房</strong>等生态型软件</li> </ul>`
keyWords := []string{"云案场", "关键词2"}
result := HighlightKeywordsInText(html, keyWords)
t.Log(result)
}
// TestHighlightKeywordsInHTML_ColorAssignment 测试颜色分配逻辑
func TestHighlightKeywordsInHTML_ColorAssignment(t *testing.T) {
// 创建一个包含所有关键词的HTML内容
keywords := make([]string, 20)
htmlParts := make([]string, 20)
for i := 0; i < 20; i++ {
keyword := "关键词" + string(rune('A'+i))
keywords[i] = keyword
htmlParts[i] = "<p>" + keyword + "</p>"
}
htmlContent := strings.Join(htmlParts, "")
result := HighlightKeywordsInHTML(htmlContent, keywords)
// 验证所有关键词都被处理应该都有span标签
spanCount := strings.Count(result, `<span style="color:`)
if spanCount != len(keywords) {
t.Errorf("期望有 %d 个span标签实际有 %d 个", len(keywords), spanCount)
}
// 验证使用了多种不同的颜色
colors := []string{
"#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A", "#98D8C8",
"#F7DC6F", "#BB8FCE", "#85C1E2", "#F8B739", "#52B788",
"#E63946", "#457B9D", "#2A9D8F", "#E9C46A", "#F4A261",
}
foundColors := make(map[string]bool)
for _, color := range colors {
if strings.Contains(result, color) {
foundColors[color] = true
}
}
// 由于有20个关键词循环使用15种颜色应该能找到至少10种不同颜色
if len(foundColors) < 10 {
t.Errorf("期望找到至少10种不同颜色实际找到 %d 种", len(foundColors))
}
}
// TestHighlightKeywordsInHTML_NoDuplicateHighlight 测试不会对已高亮的内容重复高亮
func TestHighlightKeywordsInHTML_NoDuplicateHighlight(t *testing.T) {
htmlContent := "<p>人工智能技术</p>"
pointKeys := []string{"人工智能"}
// 第一次高亮
result1 := HighlightKeywordsInHTML(htmlContent, pointKeys)
// 第二次对已高亮的内容再次高亮
result2 := HighlightKeywordsInHTML(result1, pointKeys)
// 统计span标签数量不应该无限增加
spanCount1 := strings.Count(result1, `<span`)
spanCount2 := strings.Count(result2, `<span`)
// 注意:由于正则匹配,可能会对已高亮的内容再次匹配,这是预期行为
// 这里主要验证函数不会崩溃
if spanCount2 < spanCount1 {
t.Errorf("第二次高亮后span数量不应减少: 第一次=%d, 第二次=%d", spanCount1, spanCount2)
}
}
// BenchmarkHighlightKeywordsInHTML 性能基准测试
func BenchmarkHighlightKeywordsInHTML(b *testing.B) {
htmlContent := "<p>人工智能和机器学习是计算机科学的重要分支,深度学习是机器学习的一个子领域。自然语言处理也是人工智能的重要应用方向。</p>"
pointKeys := []string{"人工智能", "机器学习", "深度学习", "自然语言处理", "计算机科学"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
HighlightKeywordsInHTML(htmlContent, pointKeys)
}
}
// BenchmarkHighlightKeywordsInText 性能基准测试
func BenchmarkHighlightKeywordsInText(b *testing.B) {
textContent := "人工智能和机器学习是计算机科学的重要分支,深度学习是机器学习的一个子领域。自然语言处理也是人工智能的重要应用方向。"
pointKeys := []string{"人工智能", "机器学习", "深度学习", "自然语言处理", "计算机科学"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
HighlightKeywordsInText(textContent, pointKeys)
}
}

View File

@ -5,11 +5,12 @@ import (
"fmt" "fmt"
"geo/internal/config" "geo/internal/config"
"github.com/gofiber/fiber/v2/log"
"regexp" "regexp"
"strings" "strings"
"time" "time"
"github.com/gofiber/fiber/v2/log"
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
"github.com/go-rod/rod" "github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto" "github.com/go-rod/rod/lib/proto"
@ -123,7 +124,7 @@ func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("获取答案失败: %v", err) return nil, fmt.Errorf("获取答案失败: %v", err)
} }
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
// 获取分享链接 // 获取分享链接
shareLink := "" shareLink := ""
link, _ := c.getShareLink() link, _ := c.getShareLink()
@ -132,8 +133,9 @@ func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
} }
return &CollectResult{ return &CollectResult{
Answer: answer, Answer: answerStr,
ShareLink: shareLink, ShareLink: shareLink,
IsExposure: isExposure,
}, nil }, nil
} }