This commit is contained in:
parent
1f79fa82ee
commit
0647790cc2
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 安全地获取元素
|
||||||
|
|
|
||||||
|
|
@ -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 收集器注册表
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue