This commit is contained in:
renzhiyuan 2026-04-26 15:56:15 +08:00
parent 9c22a1dc07
commit aa4c7a09a9
14 changed files with 916 additions and 319 deletions

BIN
after_share_icon_click Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

78
deepseek_test.go Normal file
View File

@ -0,0 +1,78 @@
package collect
import (
"context"
"geo/internal/collect"
"geo/internal/config"
"testing"
"github.com/gofiber/fiber/v2/log"
)
var (
deepseekCfg, _ = config.LoadConfig()
deepseekManager = collect.NewCollectManager(context.Background(), deepseekCfg, log.DefaultLogger())
)
// TestDeepseekCollector_WaitLogin 测试DeepSeek登录功能
func TestDeepseekCollector_WaitLogin(t *testing.T) {
if testing.Short() {
t.Skip("跳过需要浏览器交互的测试")
}
params := &collect.CollectParams{
Headless: false, // 显示浏览器窗口以便扫码登录
RequestID: "test_deepseek_login_001",
Platform: "deepseek",
}
t.Log("开始测试DeepSeek登录...")
t.Log("请在打开的浏览器窗口中完成DeepSeek账号登录扫码或输入账号密码")
success, msg := deepseekManager.WaitLogin("deepseek", params)
if !success {
t.Errorf("DeepSeek登录失败: %s", msg)
return
}
t.Logf("DeepSeek登录成功: %s", msg)
t.Log("Cookie已保存后续测试可以使用已登录状态")
}
// TestDeepseekCollector_AskQuestion 测试DeepSeek提问功能
// 注意:此测试需要有效的登录状态
func TestDeepseekCollector_AskQuestion(t *testing.T) {
if testing.Short() {
t.Skip("跳过需要浏览器交互的测试")
}
// 设置收集参数
params := &collect.CollectParams{
Headless: false, // 显示浏览器以便调试
RequestID: "test_deepseek_001",
Platform: "deepseek",
KeyWords: []string{"AI", "人工智能"}, // 测试关键词高亮
}
// 定义提问内容
question := "什么是人工智能?"
t.Logf("向DeepSeek提问: %s", question)
// 调用管理器提问并获取答案
result, err := deepseekManager.AskQuestion("deepseek", params, question)
if err != nil {
t.Errorf("提问失败: %v", err)
return
}
t.Logf("获取到答案:\n%s", result.Answer)
t.Logf("分享链接: %s", result.ShareLink)
t.Logf("是否包含关键词: %v", result.IsExposure)
// 验证答案非空
if len(result.Answer) == 0 {
t.Error("答案为空")
}
}

BIN
doubao_wait_answer_30 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -31,14 +31,8 @@ func NewCollectBiz(ctx context.Context, cfg *config.Config, logger log.AllLogger
// requestID: 请求ID
// question: 问题内容
// headless: 是否无头模式
func (b *CollectBiz) AskAIQuestion(platform string, requestID, question string, headless bool) (*collect.CollectResult, error) {
params := &collect.CollectParams{
Headless: headless,
RequestID: requestID,
Platform: platform,
}
result, err := b.manager.AskQuestion(platform, params, question)
func (b *CollectBiz) AskAIQuestion(platform string, requestID, question string, keywords []string, headless bool) (*collect.CollectResult, error) {
result, err := b.manager.AskQuestion(platform, question)
if err != nil {
return nil, fmt.Errorf("向%s提问失败: %w", platform, err)
}
@ -46,41 +40,11 @@ func (b *CollectBiz) AskAIQuestion(platform string, requestID, question string,
return result, nil
}
// WaitAILogin 等待AI平台登录
func (b *CollectBiz) WaitAILogin(platform string, requestID string, headless bool) (bool, string) {
params := &collect.CollectParams{
Headless: headless,
RequestID: requestID,
Platform: platform,
}
return b.manager.WaitLogin(platform, params)
}
// WaitAILogin 等待AI平台登录 - 已废弃
// 注意:新架构中不再需要单独的登录方法,登录状态通过 Cookie 自动维持
// ListAIPlatforms 列出所有支持的AI平台
func (b *CollectBiz) ListAIPlatforms() []string {
return b.manager.ListPlatforms()
}
// AskMultipleAI 向多个AI平台提问并收集答案
func (b *CollectBiz) AskMultipleAI(platforms []string, requestID, question string, headless bool) map[string]*collect.CollectResult {
results := make(map[string]*collect.CollectResult)
for _, platform := range platforms {
// 为每个平台生成唯一的 requestID
platformRequestID := requestID + "_" + platform
result, err := b.AskAIQuestion(platform, platformRequestID, question, headless)
if err != nil {
b.logger.Errorf("向%s提问失败: %v", platform, err)
// 创建一个包含错误信息的结果
results[platform] = &collect.CollectResult{
Answer: fmt.Sprintf("错误: %v", err),
ShareLink: "",
}
} else {
results[platform] = result
}
}
return results
platforms := []string{"wenxin", "deepseek", "doubao", "qianwen"}
return platforms
}

View File

@ -36,7 +36,7 @@
### 1. 基本使用
```go
``go
package main
import (
@ -89,7 +89,7 @@ func main() {
### 2. 多平台对比
```go
``go
// 向多个AI平台提问同一个问题
platforms := []string{"wenxin", "deepseek", "doubao", "qianwen"}
question := "什么是人工智能?"
@ -115,7 +115,7 @@ for _, platform := range platforms {
### 3. 登录管理
```go
``go
// 首次使用时需要登录
params := &collect.CollectParams{
Headless: false, // 显示浏览器窗口以便扫码登录
@ -140,7 +140,7 @@ answer, _ := manager.AskQuestion("wenxin", params, "你好")
### 4. 列出支持的平台
```go
``go
platforms := manager.ListPlatforms()
fmt.Printf("支持的平台: %v\n", platforms)
// 输出: 支持的平台: [wenxin deepseek doubao qianwen]
@ -150,7 +150,7 @@ fmt.Printf("支持的平台: %v\n", platforms)
### 必需的配置项
```go
``go
type SysConfig struct {
ChromePath string // Chrome浏览器可执行文件路径
ChromeDataDir string // Chrome用户数据目录
@ -161,7 +161,7 @@ type SysConfig struct {
### 示例配置
```go
``go
cfg := &config.Config{
Sys: config.SysConfig{
ChromePath: "/usr/bin/google-chrome", // Linux
@ -229,7 +229,7 @@ cfg := &config.Config{
示例:
```go
``go
package collect
import (
@ -257,7 +257,7 @@ func NewNewPlatformCollector(ctx context.Context, params *CollectParams, cfg *co
然后在 `interface.go` 中注册:
```go
``go
var CollectorMap = map[string]*CollectorValue{
// ... 其他平台
"newplatform": {
@ -304,3 +304,316 @@ var CollectorMap = map[string]*CollectorValue{
## 许可证
与项目主许可证保持一致。
# Collect 模块 - 单浏览器多Page架构
## 架构说明
本模块采用**单浏览器多Page模式**,服务启动时创建一个全局浏览器实例,并为每个平台打开一个常驻 Page。
### 核心特性
1. **单一浏览器**:所有平台共用同一个浏览器实例
2. **启动时预创建**:服务启动时立即创建浏览器并打开所有平台的页面
3. **Page 常驻**:每个平台的 Page 在整个服务生命周期内保持活跃
4. **强制有头模式**:便于调试和人工干预(如扫码登录)
5. **统一管理**Browser 和 Page 都由 Manager 统一管理和关闭
## 使用示例
### 基本用法
``go
package main
import (
"context"
"geo/internal/collect"
"geo/internal/config"
"github.com/gofiber/fiber/v2/log"
)
func main() {
cfg, _ := config.LoadConfig()
ctx := context.Background()
// 创建管理器(会自动:启动浏览器 + 打开所有平台页面)
manager := collect.NewCollectManager(ctx, cfg, log.DefaultLogger())
// ⚠️ 重要:确保程序退出时关闭所有资源
defer manager.Close()
// 每次调用都使用对应的常驻 Page
params := &collect.CollectParams{
RequestID: "req_001",
Platform: "wenxin",
KeyWords: []string{"AI", "人工智能"},
}
// 提问(使用 wenxin 的常驻 Page
result, err := manager.AskQuestion("wenxin", params, "什么是人工智能?")
if err != nil {
log.Errorf("提问失败: %v", err)
return
}
log.Infof("答案长度: %d", len(result.Answer))
log.Infof("分享链接: %s", result.ShareLink)
// 可以切换到其他平台(使用对应的常驻 Page
result2, _ := manager.AskQuestion("deepseek", params, "第二个问题")
}
```
### 登录测试示例
``go
func loginTest(manager *collect.CollectManager) {
params := &collect.CollectParams{
RequestID: "login_test_001",
Platform: "deepseek",
}
// 等待登录(使用 deepseek 的常驻 Page
// 浏览器窗口已经打开,可以直接看到页面并进行扫码登录
success, msg := manager.WaitLogin("deepseek", params)
if !success {
log.Errorf("登录失败: %s", msg)
return
}
log.Infof("登录成功: %s", msg)
// Cookie 已自动保存,下次可以直接使用
}
```
### 并发操作示例
``go
func concurrentExample(manager *collect.CollectManager) {
// 可以安全地并发调用不同平台
go func() {
params := &collect.CollectParams{
RequestID: "req_001",
Platform: "wenxin",
}
result, _ := manager.AskQuestion("wenxin", params, "问题1")
log.Infof("文心一言回答: %s", result.Answer)
}()
go func() {
params := &collect.CollectParams{
RequestID: "req_002",
Platform: "deepseek",
}
result, _ := manager.AskQuestion("deepseek", params, "问题2")
log.Infof("DeepSeek回答: %s", result.Answer)
}()
}
```
## 架构细节
### 浏览器管理
- **数量**:全局只有一个浏览器实例
- **创建时机**`NewCollectManager()` 调用时立即创建
- **存储方式**`manager.browser` 字段
- **线程安全**browser 只读访问,无竞态条件
- **生命周期**:从服务启动到 `manager.Close()` 调用
- **关闭方式**:调用 `manager.Close()` 关闭浏览器和所有 Page
### Page 管理
- **数量**:每个平台一个 Page共 4 个wenxin, deepseek, doubao, qianwen
- **创建时机**`NewCollectManager()` 调用时为所有平台打开页面
- **存储方式**`map[string]*rod.Page`key 为平台名称
- **线程安全**:使用 `sync.RWMutex` 保护并发访问
- **生命周期**:从服务启动到 `manager.Close()` 调用
- **特点**Page 常驻,不会在操作后关闭
### 数据流
```
服务启动
NewCollectManager()
创建全局浏览器1个
为每个平台打开 Page4个
├─ wenxin Page → https://yiyan.baidu.com/
├─ deepseek Page → https://chat.deepseek.com/
├─ doubao Page → https://www.doubao.com/chat/
└─ qianwen Page → https://tongyi.aliyun.com/qianwen/
所有 Page 保持活跃(可看到浏览器窗口)
每次请求:
AskQuestion(platform, ...)
获取对应平台的常驻 Page
执行操作(输入、点击等)
返回结果Page 保持活跃,不关闭)
服务关闭:
manager.Close()
关闭所有 Page
关闭浏览器
```
### 数据存储
```
ChromeDataDir/
└── global/
└── main/ # 全局浏览器用户数据(所有平台共用)
CookiesDir/
├── wenxin/
│ └── wenxin.json # Cookie 文件(按平台隔离)
├── deepseek/
│ └── deepseek.json
└── doubao/
└── doubao.json
```
## 关键优势
### 1. 资源占用最小化
- ✅ 只启动一个浏览器进程
- ✅ 内存占用最低
- ✅ 系统资源消耗最少
### 2. 启动速度快
- ✅ 浏览器在服务启动时已就绪
- ✅ 所有平台页面已打开
- ✅ 无需等待页面加载
### 3. 调试友好
- ✅ 有头模式,可实时观察所有平台
- ✅ 支持人工干预(扫码、验证码等)
- ✅ 便于问题排查
### 4. 会话保持
- ✅ Page 常驻,登录状态持续有效
- ✅ Cookie 自动保存和加载
- ✅ 无需重复登录
## 注意事项
### ⚠️ 必须调用 Close()
``go
manager := collect.NewCollectManager(ctx, cfg, logger)
defer manager.Close() // 确保程序退出时关闭所有资源
```
如果不调用 `Close()`,浏览器进程会残留。
### ⚠️ 有头模式限制
- 所有浏览器都以有头模式运行
- 无法切换到无头模式
- 适合开发和调试环境
### ⚠️ 并发访问注意
虽然使用了 `sync.RWMutex` 保护 pages map但 rod 的 Page 本身不是线程安全的。建议:
- 同一平台的请求串行执行
- 不同平台可以并发执行
- 避免在同一 Page 上同时执行多个操作
### ⚠️ Cookie 隔离
虽然浏览器是共用的,但 Cookie 文件按平台隔离存储,确保各平台的会话独立。
## 迁移指南
### 变化点
1. ✅ `NewCollectManager()` 会立即启动浏览器并打开所有平台页面
2. ✅ Page 是常驻的,不会在操作后关闭
3. ✅ `Collector.Close()` 不再关闭 Page空实现
4. ✅ `Headless` 参数被忽略,强制为 `false`
### 无需修改
- ❌ Manager 的创建方式不变
- ❌ 业务代码调用方式不变
- ❌ Collector 的业务逻辑无需修改
## 性能对比
| 指标 | 旧架构 | 新架构 |
|------|--------|--------|
| 浏览器进程数 | 4个每平台1个 | 1个全局共用 |
| 首次启动 | ~10秒 | ~5秒 |
| 后续请求 | ~0.1秒 | ~0.1秒 |
| 内存占用 | 高 | 低 |
| 并发能力 | 中 | 中需注意Page线程安全 |
| 资源泄漏风险 | 低 | 低 |
## 最佳实践
1. **服务启动时初始化**
```go
func initService() {
manager = collect.NewCollectManager(ctx, cfg, logger)
}
```
2. **服务关闭时清理**
```go
func shutdownService() {
manager.Close()
}
```
3. **异常处理**
```go
result, err := manager.AskQuestion(platform, params, question)
if err != nil {
log.Errorf("操作失败: %v", err)
// Page 仍然可用,可以继续尝试
}
```
4. **监控建议**
- 监控浏览器进程数量应该只有1个
- 监控内存使用情况
- 记录每次操作的耗时
- 监控各平台 Page 的健康状态
## 架构图
```
┌─────────────────────────────────────┐
│ CollectManager │
│ │
│ ┌───────────────────────────────┐ │
│ │ Global Browser (1个) │ │
│ │ Headless: false │ │
│ │ Window: 1920x1080 │ │
│ └───────────────────────────────┘ │
│ │ │
│ ├──────────────────┐ │
│ │ │ │
│ ┌───────▼───────┐ ┌──────▼──┐│
│ │ Wenxin Page │ │Deepseek ││
│ │ (常驻) │ │Page ││
│ └───────────────┘ └─────────┘│
│ │ │ │
│ ┌───────▼───────┐ ┌──────▼──┐│
│ │ Doubao Page │ │Qianwen ││
│ │ (常驻) │ │Page ││
│ └───────────────┘ └─────────┘│
└─────────────────────────────────────┘
```

View File

@ -10,7 +10,6 @@ import (
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
"github.com/gofiber/fiber/v2/log"
)
@ -38,8 +37,8 @@ type BaseCollector struct {
RetryDelay int
}
// NewBaseCollector 构造函数
func NewBaseCollector(ctx context.Context, params *CollectParams, config *config.Config, logger log.AllLogger) *BaseCollector {
// NewBaseCollector 构造函数 - 接收已有的 browser 和 page
func NewBaseCollector(ctx context.Context, params *CollectParams, config *config.Config, logger log.AllLogger, browser *rod.Browser, page *rod.Page) *BaseCollector {
var baseLogger log.AllLogger
if logger != nil {
@ -50,12 +49,14 @@ func NewBaseCollector(ctx context.Context, params *CollectParams, config *config
base := &BaseCollector{
ctx: ctx,
Headless: params.Headless,
Headless: false, // 强制有头模式
RequestID: params.RequestID,
Platform: params.Platform,
KeyWords: params.KeyWords,
Logger: baseLogger,
config: config,
Browser: browser,
Page: page,
MaxRetries: 3,
RetryDelay: 200,
}
@ -65,52 +66,22 @@ func NewBaseCollector(ctx context.Context, params *CollectParams, config *config
return base
}
// SetupDriver 初始化浏览器驱动
// SetupDriver 初始化浏览器驱动 - 在常驻浏览器模式下,此方法不再需要创建浏览器
// 保留此方法仅为兼容性,实际不做任何操作
func (b *BaseCollector) SetupDriver() error {
userDataDir := filepath.Join(b.config.Sys.ChromeDataDir, b.Platform, b.RequestID+fmt.Sprintf("___%d", time.Now().UnixNano()))
os.MkdirAll(userDataDir, 0755)
l := launcher.New().
Bin(b.config.Sys.ChromePath).
UserDataDir(userDataDir).
Headless(b.Headless).
Leakless(false).
Set("disable-blink-features", "AutomationControlled")
if b.Headless {
l.Set("headless", "new")
l.Set("disable-gpu")
l.Set("no-sandbox")
l.Set("disable-dev-shm-usage")
} else {
l.Set("window-size", "1920,1080")
l.Set("start-maximized")
l.Delete("headless")
// 在常驻浏览器模式下Browser 和 Page 已由 Manager 创建
if b.Browser == nil || b.Page == nil {
return fmt.Errorf("Browser 或 Page 未初始化")
}
l.Set("lang", "zh-CN")
l.Set("accept-lang", "zh-CN,zh;q=0.9,en;q=0.8")
l.Set("force-device-scale-factor", "1")
l.Set("timezone", "Asia/Shanghai")
url, err := l.Launch()
if err != nil {
return fmt.Errorf("启动浏览器失败: %v", err)
}
b.Browser = rod.New().Context(b.ctx).ControlURL(url).MustConnect()
b.Page = b.Browser.MustPage()
return nil
}
// Close 关闭浏览器
// Close 关闭页面 - 如果是临时 Page则关闭它
func (b *BaseCollector) Close() {
if b.Page != nil {
b.Page.Close()
}
if b.Browser != nil {
b.Browser.Close()
b.Page.MustClose()
b.Page = nil
b.LogInfo("🔒 临时 Page 已关闭")
}
}
@ -273,13 +244,24 @@ func (b *BaseCollector) AskQuestion(question string) (*CollectResult, error) {
return nil, fmt.Errorf("需要实现")
}
// InitPage 初始化页面
// InitPage 初始化页面 - 如果 Page 为 nil则创建临时 Page
func (b *BaseCollector) InitPage() error {
// 如果 Page 为 nil创建临时 Page
if b.Page == nil {
b.Page = b.Browser.MustPage()
b.LogInfo("✅ 创建临时 Page")
}
// 尝试加载cookies
if err := b.LoadCookies(); err == nil {
b.Page.MustNavigate(b.ChatURL)
b.WaitForPageReady(5)
} else {
// 如果没有 cookies直接导航到聊天页面
b.Page.MustNavigate(b.ChatURL)
b.WaitForPageReady(5)
}
b.SaveCookies()
return nil
}

View File

@ -4,9 +4,11 @@ import (
"context"
"fmt"
"geo/internal/config"
"regexp"
"strings"
"time"
"github.com/atotto/clipboard"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/gofiber/fiber/v2/log"
@ -18,9 +20,9 @@ type DeepseekCollector struct {
}
// NewDeepseekCollector 创建DeepSeek收集器
func NewDeepseekCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger) CollectorInterface {
func NewDeepseekCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger, browser *rod.Browser, page *rod.Page) CollectorInterface {
collector := &DeepseekCollector{
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
BaseCollector: NewBaseCollector(ctx, params, cfg, logger, browser, page),
}
// 设置DeepSeek的URL
@ -33,23 +35,12 @@ func NewDeepseekCollector(ctx context.Context, params *CollectParams, cfg *confi
// CheckLoginStatus 检查登录状态
func (c *DeepseekCollector) CheckLoginStatus() bool {
currentURL := c.GetCurrentURL()
// 如果在首页或登录页面,可能未登录
if strings.Contains(currentURL, "chat.deepseek.com") {
// 检查是否有用户头像或登录标识
userAvatar, err := c.SafeElement(".user-avatar, [class*='avatar'], [class*='profile']")
if err == nil && userAvatar != nil {
return true
}
// 检查是否有聊天输入框(登录后才有)
inputBox, err := c.SafeElement("textarea, [contenteditable='true']")
if err == nil && inputBox != nil {
return true
}
c.LogInfo(fmt.Sprintf("当前URL: %s", currentURL))
if currentURL == c.LoginURL {
return false
}
return false
return true
}
// WaitLogin 等待登录
@ -67,6 +58,9 @@ func (c *DeepseekCollector) WaitLogin() (bool, string) {
return true, "already_logged_in"
}
c.LogInfo("未检测到登录状态,等待用户登录...")
// 最多等待300秒
for i := 0; i < 300; i++ {
if c.CheckLoginStatus() {
c.Sleep(2)
@ -81,10 +75,7 @@ func (c *DeepseekCollector) WaitLogin() (bool, string) {
// AskQuestion 提问并获取答案
func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error) {
if err := c.SetupDriver(); err != nil {
return nil, fmt.Errorf("浏览器启动失败: %v", err)
}
defer c.Close()
// 注意SetupDriver 和 Close 已由 Manager 管理,这里不再调用
if err := c.InitPage(); err != nil {
return nil, fmt.Errorf("页面初始化失败: %v", err)
@ -105,14 +96,27 @@ func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error)
return nil, fmt.Errorf("获取答案失败: %v", err)
}
// 关键词高亮处理
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
// 获取分享链接
shareLink := ""
link, _ := c.getShareLink()
if link != "" {
shareLink = link
}
return &CollectResult{
Answer: answer,
ShareLink: "",
Answer: answerStr,
ShareLink: shareLink,
IsExposure: isExposure,
}, nil
}
// inputQuestion 输入问题
func (c *DeepseekCollector) inputQuestion(question string) error {
c.LogInfo("输入问题...")
// DeepSeek的输入框选择器
inputSelectors := []string{
"textarea[placeholder*='输入']",
@ -129,6 +133,7 @@ func (c *DeepseekCollector) inputQuestion(question string) error {
for _, selector := range inputSelectors {
inputBox, err = c.WaitForElementVisible(selector, 10)
if err == nil && inputBox != nil {
c.LogInfo(fmt.Sprintf("找到输入框: %s", selector))
break
}
}
@ -143,17 +148,12 @@ func (c *DeepseekCollector) inputQuestion(question string) error {
}
c.SleepMs(500)
// 清空输入框
if err := c.ClearInput(inputBox); err != nil {
// Ignore clear error
}
c.SleepMs(300)
// 输入问题
if err := c.SetInputValue(inputBox, question); err != nil {
inputBox.Input(question)
}
// fallback: 使用Focus + Input
inputBox.Focus()
c.SleepMs(200)
inputBox.Input(question)
c.LogInfo(fmt.Sprintf("问题已输入: %s", question))
c.SleepMs(1000)
return nil
@ -161,96 +161,349 @@ func (c *DeepseekCollector) inputQuestion(question string) error {
// clickSendButton 点击发送按钮
func (c *DeepseekCollector) clickSendButton() error {
// 发送按钮选择器
sendSelectors := []string{
"button[class*='send']",
"button[class*='submit']",
".send-button",
".submit-button",
"button svg[path*='send']",
"[aria-label*='发送']",
"[aria-label*='Send']",
c.LogInfo("点击发送按钮...")
// 使用正则匹配包含"send"或"提交"的class
allElements, err := c.Page.Elements("*")
if err != nil {
return fmt.Errorf("获取页面元素失败: %v", err)
}
var sendBtn *rod.Element
var err error
for _, selector := range sendSelectors {
sendBtn, err = c.WaitForElementClickable(selector, 5)
if err == nil && sendBtn != nil {
break
for _, elem := range allElements {
classAttr, _ := elem.Attribute("class")
if classAttr != nil {
classLower := strings.ToLower(*classAttr)
if strings.Contains(classLower, "send") || strings.Contains(classLower, "submit") {
// 检查是否是可点击的元素button、div等
tagName, _ := elem.Property("tagName")
if tagName.Str() == "BUTTON" || tagName.Str() == "DIV" || tagName.Str() == "SVG" {
sendBtn = elem
c.LogInfo(fmt.Sprintf("通过正则找到发送按钮: class=%s, tag=%s", *classAttr, tagName.Str()))
break
}
}
}
}
if sendBtn == nil {
// 尝试查找发送图标
sendBtn, err = c.Page.Element("button svg")
if err != nil {
return fmt.Errorf("未找到发送按钮")
// fallback: 尝试查找发送图标或最后一个button
buttons, _ := c.Page.Elements("button")
if len(buttons) > 0 {
sendBtn = buttons[len(buttons)-1]
c.LogInfo("使用最后一个button作为发送按钮")
}
}
if sendBtn == nil {
// 尝试查找SVG图标
svgs, _ := c.Page.Elements("svg")
for _, svg := range svgs {
parent, _ := svg.Parent()
if parent != nil {
tagName, _ := parent.Property("tagName")
if tagName.Str() == "BUTTON" {
sendBtn = parent
c.LogInfo("使用包含SVG的button作为发送按钮")
break
}
}
}
}
if sendBtn == nil {
return fmt.Errorf("未找到发送按钮")
}
c.SleepMs(500)
// 滚动到可见区域
if err := sendBtn.ScrollIntoView(); err != nil {
c.LogInfo(fmt.Sprintf("滚动失败: %v", err))
}
c.SleepMs(300)
// 点击发送按钮
if err := c.JSClick(sendBtn); err != nil {
c.LogInfo("执行点击...")
if err := sendBtn.Click(proto.InputMouseButtonLeft, 1); err != nil {
return fmt.Errorf("点击发送按钮失败: %v", err)
}
c.LogInfo("已点击发送按钮")
c.SleepMs(2000)
return nil
}
// waitForAnswer 等待并获取答案
// waitForAnswer 等待并获取答案(处理流式输出)
func (c *DeepseekCollector) waitForAnswer() (string, error) {
timeout := 120 // 最大等待时间(秒)
c.LogInfo("等待AI回答...")
timeout := 180 // 最大等待时间(秒)
startTime := time.Now()
lastAnswerLength := 0
var lastAnswer string
var stableCount int
const requiredStableCount = 5 // 需要连续5次内容不变才认为完成
isAnswering := false
for time.Since(startTime).Seconds() < float64(timeout) {
// 查找答案区域
// 查找答案区域 - 尝试多种选择器
answerSelectors := []string{
".message-content",
".response-content",
"[class*='assistant'] [class*='content']",
"[class*='ai'] [class*='message']",
".chat-message.ai",
"[class*='answer']",
"[class*='response']",
}
var answerText string
var foundAnswer bool
for _, selector := range answerSelectors {
answerElements, err := c.Page.Elements(selector)
if err == nil && len(answerElements) > 0 {
// 获取最后一个答案元素
// 获取最后一个答案元素(最新的回答)
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, "generating")
// 尝试获取HTML内容
htmlContent, err := lastAnswer.HTML()
if err == nil && len(strings.TrimSpace(htmlContent)) > 30 {
answerText = CleanDivTags(htmlContent)
foundAnswer = true
c.LogInfo(fmt.Sprintf("找到答案(%s清理后文本长度: %d", selector, len(answerText)))
break
}
if !isGenerating {
// 检查答案是否还在增长
currentLength := len(text)
if currentLength == lastAnswerLength && currentLength > 10 {
// 答案不再增长,认为已完成
return strings.TrimSpace(text), nil
}
lastAnswerLength = currentLength
}
// 如果HTML获取失败尝试获取文本
textContent, _ := lastAnswer.Text()
if len(strings.TrimSpace(textContent)) > 30 {
answerText = strings.TrimSpace(textContent)
foundAnswer = true
c.LogInfo(fmt.Sprintf("找到答案(%s文本长度: %d", selector, len(answerText)))
break
}
}
}
}
c.SleepMs(1500)
if !foundAnswer {
c.LogInfo("未找到答案元素,继续等待...")
}
// 检查是否获取到答案
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))
}
}
return "", fmt.Errorf("等待答案超时")
return "", fmt.Errorf("等待答案超时(%d秒", timeout)
}
// getShareLink 获取分享链接
func (c *DeepseekCollector) getShareLink() (string, error) {
c.LogInfo("=== 开始获取分享链接 ===")
// 步骤1: 查找分享按钮需要根据DeepSeek实际页面结构调整
c.LogInfo("步骤1: 查找分享按钮...")
var shareBtn *rod.Element
// 尝试多种方式查找分享按钮
shareSelectors := []string{
"[class*='share']",
"[aria-label*='分享']",
"[aria-label*='Share']",
"button svg[path*='share']",
".share-button",
".share-icon",
}
for _, selector := range shareSelectors {
btns, err := c.Page.Elements(selector)
if err == nil && len(btns) > 0 {
shareBtn = btns[0]
c.LogInfo(fmt.Sprintf("✓ 找到分享按钮: %s", selector))
break
}
}
if shareBtn == nil {
// fallback: 遍历所有元素查找包含share的class
allElements, _ := c.Page.Elements("*")
for _, elem := range allElements {
classAttr, _ := elem.Attribute("class")
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "share") {
tagName, _ := elem.Property("tagName")
if tagName.Str() == "BUTTON" || tagName.Str() == "DIV" || tagName.Str() == "SVG" {
shareBtn = elem
c.LogInfo(fmt.Sprintf("✓ 通过正则找到分享按钮: tag=%s, class=%s", tagName.Str(), *classAttr))
break
}
}
}
}
if shareBtn == nil {
c.LogInfo("未找到分享按钮,跳过获取分享链接")
return "", fmt.Errorf("未找到分享按钮")
}
// 滚动到元素位置
c.LogInfo("滚动到分享按钮位置...")
if scrollErr := shareBtn.ScrollIntoView(); scrollErr != nil {
c.LogInfo(fmt.Sprintf("滚动失败: %v", scrollErr))
}
c.SleepMs(800)
// 点击分享按钮
c.LogInfo("执行点击分享按钮...")
if clickErr := shareBtn.Click(proto.InputMouseButtonLeft, 1); clickErr != nil {
return "", fmt.Errorf("点击分享按钮失败: %v", clickErr)
}
c.LogInfo("✓ 点击成功")
c.SleepMs(3000) // 等待弹窗出现
c.Screenshot("after_share_click")
// 步骤2: 在弹窗中查找复制链接按钮(带重试机制)
c.LogInfo("步骤2: 查找复制链接按钮...")
var copyLinkBtn *rod.Element
maxRetries := 5
retryDelay := 1000
for attempt := 1; attempt <= maxRetries; attempt++ {
c.LogInfo(fmt.Sprintf("第 %d/%d 次尝试查找复制链接按钮...", attempt, maxRetries))
// 尝试多种方式查找复制按钮
copySelectors := []string{
"[class*='copy']",
"[class*='Copy']",
"[aria-label*='复制']",
"[aria-label*='Copy']",
"button[class*='link']",
}
for _, selector := range copySelectors {
btns, err := c.Page.Elements(selector)
if err == nil && len(btns) > 0 {
copyLinkBtn = btns[0]
c.LogInfo(fmt.Sprintf("✓ 找到复制链接按钮: %s", selector))
break
}
}
if copyLinkBtn != nil {
break
}
// fallback: 遍历所有元素
allElements, _ := c.Page.Elements("*")
for _, elem := range allElements {
classAttr, _ := elem.Attribute("class")
if classAttr != nil {
classLower := strings.ToLower(*classAttr)
if strings.Contains(classLower, "copy") || strings.Contains(classLower, "link") {
tagName, _ := elem.Property("tagName")
if tagName.Str() == "BUTTON" || tagName.Str() == "DIV" {
copyLinkBtn = elem
c.LogInfo(fmt.Sprintf("✓ 通过正则找到复制按钮: tag=%s, class=%s", tagName.Str(), *classAttr))
break
}
}
}
}
if copyLinkBtn != nil {
break
}
// 没找到,等待后重试
if attempt < maxRetries {
c.LogInfo(fmt.Sprintf("未找到复制链接按钮,%d毫秒后重试...", retryDelay))
c.SleepMs(retryDelay)
}
}
if copyLinkBtn == nil {
c.Screenshot("copy_button_not_found")
return "", fmt.Errorf("经过 %d 次重试仍未找到复制链接按钮", maxRetries)
}
// 滚动到按钮位置
c.LogInfo("滚动到复制链接按钮位置...")
if scrollErr := copyLinkBtn.ScrollIntoView(); scrollErr != nil {
c.LogInfo(fmt.Sprintf("滚动失败: %v", scrollErr))
}
c.SleepMs(500)
// 点击复制链接按钮
c.LogInfo("点击复制链接按钮...")
if clickErr := copyLinkBtn.Click(proto.InputMouseButtonLeft, 1); clickErr != nil {
return "", fmt.Errorf("点击复制链接按钮失败: %v", clickErr)
}
c.LogInfo("✓ 复制链接按钮点击成功")
c.SleepMs(1500) // 等待复制链接完成
// 步骤3: 从剪贴板读取分享链接
c.LogInfo("步骤3: 从系统剪贴板读取分享链接...")
clipboardText, err := clipboard.ReadAll()
if err != nil {
return "", fmt.Errorf("读取剪贴板失败: %v", err)
}
if clipboardText == "" {
return "", fmt.Errorf("剪贴板内容为空")
}
c.LogInfo(fmt.Sprintf("剪贴板原始内容: %s", clipboardText))
// 使用正则表达式提取URL
re := regexp.MustCompile(`https?://[^\s]+`)
matches := re.FindStringSubmatch(clipboardText)
if len(matches) == 0 {
return "", fmt.Errorf("未能从剪贴板内容中提取URL")
}
url := matches[0]
c.LogInfo(fmt.Sprintf("✓✓✓ 成功获取分享链接: %s", url))
return url, nil
}
// SafeElement 安全地获取元素

View File

@ -19,9 +19,9 @@ type DoubaoCollector struct {
}
// NewDoubaoCollector 创建豆包收集器
func NewDoubaoCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger) CollectorInterface {
func NewDoubaoCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger, browser *rod.Browser, page *rod.Page) CollectorInterface {
collector := &DoubaoCollector{
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
BaseCollector: NewBaseCollector(ctx, params, cfg, logger, browser, page),
}
// 设置豆包的URL
@ -98,20 +98,10 @@ func (c *DoubaoCollector) WaitLogin() (bool, string) {
// 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 {

View File

@ -4,15 +4,16 @@ import (
"context"
"geo/internal/config"
"github.com/go-rod/rod"
"github.com/gofiber/fiber/v2/log"
)
// CollectorInterface AI平台收集器接口
type CollectorInterface interface {
// WaitLogin 等待登录
WaitLogin() (bool, string)
// AskQuestion 提问并获取答案
AskQuestion(question string) (*CollectResult, error)
// Close 关闭页面(释放资源)
Close()
}
// CollectResult 收集结果
@ -27,7 +28,9 @@ type NewCollector func(
ctx context.Context,
param *CollectParams,
cfg *config.Config,
logger log.AllLogger) CollectorInterface
logger log.AllLogger,
browser *rod.Browser,
page *rod.Page) CollectorInterface
// CollectorValue 收集器配置信息
type CollectorValue struct {

View File

@ -4,66 +4,129 @@ import (
"context"
"fmt"
"geo/internal/config"
"os"
"path/filepath"
"sync"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/gofiber/fiber/v2/log"
)
// CollectManager 收集管理器
// CollectManager 收集管理器 - 单浏览器临时Page模式
type CollectManager struct {
ctx context.Context
config *config.Config
logger log.AllLogger
browser *rod.Browser // 全局唯一的浏览器实例
mu sync.RWMutex // 保护浏览器访问的互斥锁
collectors map[string]CollectorInterface // 平台名称 -> Collector 实例
}
// NewCollectManager 创建收集管理器
// NewCollectManager 创建收集管理器(服务启动时调用)
func NewCollectManager(ctx context.Context, cfg *config.Config, logger log.AllLogger) *CollectManager {
return &CollectManager{
ctx: ctx,
config: cfg,
logger: logger,
manager := &CollectManager{
ctx: ctx,
config: cfg,
logger: logger,
collectors: make(map[string]CollectorInterface),
}
// 注册所有平台的 Collector
manager.registerCollectors()
// 服务启动时立即创建全局浏览器
manager.initBrowser()
return manager
}
// GetCollector 获取指定平台的收集器
func (m *CollectManager) GetCollector(platform string, params *CollectParams) (CollectorInterface, error) {
collectorValue, ok := CollectorMap[platform]
// initBrowser 初始化全局浏览器实例
func (m *CollectManager) initBrowser() {
m.logger.Info("🚀 正在初始化浏览器...")
// 创建用户数据目录(所有平台共用)
userDataDir := filepath.Join(m.config.Sys.ChromeDataDir, "global", "main")
os.MkdirAll(userDataDir, 0755)
// 使用 launcher 启动 Chrome
l := launcher.New().
Bin(m.config.Sys.ChromePath).
UserDataDir(userDataDir).
Headless(false). // 有头模式,显示窗口
Leakless(true). // 让 Chrome 进程独立运行
Set("disable-blink-features", "AutomationControlled").
Set("window-size", "1920,1080").
Set("start-maximized", "").
Set("lang", "zh-CN")
url, err := l.Launch()
if err != nil {
m.logger.Errorf("❌ 启动浏览器失败: %v", err)
panic(fmt.Sprintf("启动浏览器失败: %v", err))
}
m.logger.Infof("✅ 浏览器启动成功: %s", url)
// 连接到浏览器
m.browser = rod.New().Context(m.ctx).ControlURL(url).MustConnect()
m.logger.Info("✅ 浏览器连接成功")
}
// Close 关闭管理器(注意:不关闭浏览器,让浏览器进程继续运行)
func (m *CollectManager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
m.logger.Info("🔒 CollectManager 关闭(浏览器保持运行)...")
// 注意:这里不调用 m.browser.Close()
// 让浏览器进程继续运行,下次服务启动时可以复用
m.logger.Info("✅ Manager 已关闭,浏览器仍在后台运行")
}
// registerCollectors 注册所有平台的 Collector
func (m *CollectManager) registerCollectors() {
// 文心一言
params := &CollectParams{Platform: "wenxin"}
m.collectors["wenxin"] = NewWenxinCollector(m.ctx, params, m.config, m.logger, m.browser, nil)
// DeepSeek
params = &CollectParams{Platform: "deepseek"}
m.collectors["deepseek"] = NewDeepseekCollector(m.ctx, params, m.config, m.logger, m.browser, nil)
// 豆包
params = &CollectParams{Platform: "doubao"}
m.collectors["doubao"] = NewDoubaoCollector(m.ctx, params, m.config, m.logger, m.browser, nil)
// 通义千问
params = &CollectParams{Platform: "qianwen"}
m.collectors["qianwen"] = NewQianwenCollector(m.ctx, params, m.config, m.logger, m.browser, nil)
}
// GetCollector 获取指定平台的 Collector
func (m *CollectManager) GetCollector(platform string) (CollectorInterface, error) {
m.mu.RLock()
defer m.mu.RUnlock()
collector, ok := m.collectors[platform]
if !ok {
return nil, fmt.Errorf("不支持的平台: %s", platform)
}
collector := collectorValue.InitMethod(m.ctx, params, m.config, m.logger)
if collector == nil {
return nil, fmt.Errorf("创建收集器失败: %s", platform)
}
return collector, nil
}
// AskQuestion 向指定AI平台提问
func (m *CollectManager) AskQuestion(platform string, params *CollectParams, question string) (*CollectResult, error) {
collector, err := m.GetCollector(platform, params)
// AskQuestion 向指定平台提问(每次创建临时 Page
func (m *CollectManager) AskQuestion(platform string, question string) (*CollectResult, error) {
collector, err := m.GetCollector(platform)
if err != nil {
return nil, err
}
return collector.AskQuestion(question)
}
// WaitLogin 等待指定平台登录
func (m *CollectManager) WaitLogin(platform string, params *CollectParams) (bool, string) {
collector, err := m.GetCollector(platform, params)
if err != nil {
return false, err.Error()
}
return collector.WaitLogin()
}
// ListPlatforms 列出所有支持的平台
func (m *CollectManager) ListPlatforms() []string {
platforms := make([]string, 0, len(CollectorMap))
for platform := range CollectorMap {
platforms = append(platforms, platform)
}
return platforms
}

View File

@ -18,9 +18,9 @@ type QianwenCollector struct {
}
// NewQianwenCollector 创建通义千问收集器
func NewQianwenCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger) CollectorInterface {
func NewQianwenCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger, browser *rod.Browser, page *rod.Page) CollectorInterface {
collector := &QianwenCollector{
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
BaseCollector: NewBaseCollector(ctx, params, cfg, logger, browser, page),
}
// 设置通义千问的URL
@ -81,10 +81,7 @@ func (c *QianwenCollector) WaitLogin() (bool, string) {
// AskQuestion 提问并获取答案
func (c *QianwenCollector) AskQuestion(question string) (*CollectResult, error) {
if err := c.SetupDriver(); err != nil {
return nil, fmt.Errorf("浏览器启动失败: %v", err)
}
defer c.Close()
// 注意SetupDriver 和 Close 已由 Manager 管理,这里不再调用
if err := c.InitPage(); err != nil {
return nil, fmt.Errorf("页面初始化失败: %v", err)

View File

@ -5,12 +5,11 @@ import (
"fmt"
"geo/internal/config"
"github.com/gofiber/fiber/v2/log"
"regexp"
"strings"
"time"
"github.com/gofiber/fiber/v2/log"
"github.com/atotto/clipboard"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
@ -30,9 +29,9 @@ type WenxinCollector struct {
}
// NewWenxinCollector 创建文心一言收集器
func NewWenxinCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger) CollectorInterface {
func NewWenxinCollector(ctx context.Context, params *CollectParams, cfg *config.Config, logger log.AllLogger, browser *rod.Browser, page *rod.Page) CollectorInterface {
collector := &WenxinCollector{
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
BaseCollector: NewBaseCollector(ctx, params, cfg, logger, browser, page),
}
// 设置文心一言的URL
@ -101,15 +100,14 @@ func (c *WenxinCollector) WaitLogin() (bool, string) {
// AskQuestion 提问并获取答案
func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
if err := c.SetupDriver(); err != nil {
return nil, fmt.Errorf("浏览器启动失败: %v", err)
}
defer c.Close()
// 注意SetupDriver 和 Close 已由 Manager 管理,这里不再调用
if err := c.InitPage(); err != nil {
return nil, fmt.Errorf("页面初始化失败: %v", err)
}
c.Sleep(3)
if err := c.inputQuestion(question); err != nil {
return nil, fmt.Errorf("输入问题失败: %v", err)
}
@ -122,7 +120,7 @@ func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
if err != nil {
return nil, fmt.Errorf("获取答案失败: %v", err)
}
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
// 获取分享链接
shareLink := ""
link, _ := c.getShareLink()
@ -131,9 +129,8 @@ func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
}
return &CollectResult{
Answer: answerStr,
ShareLink: shareLink,
IsExposure: isExposure,
Answer: answer,
ShareLink: shareLink,
}, nil
}
@ -145,6 +142,7 @@ func (c *WenxinCollector) inputQuestion(question string) error {
inputSelectors := []string{
"[contenteditable='true']",
"div[contenteditable]",
".editable__T7WAW4uW",
"[class*='editable']",
}
@ -483,105 +481,61 @@ func (c *WenxinCollector) getShareLink() (string, error) {
}
c.LogInfo("✓ 点击成功")
c.SleepMs(3000) // 等待弹窗出现
c.SleepMs(2000) // 等待弹窗出现
c.Screenshot("after_share_icon_click")
// 步骤3: 在弹窗中查找shareContainer的div(带重试机制)
// 步骤3: 在弹窗中查找shareContainer的div
c.LogInfo("步骤3: 查找包含'shareContainer'的div元素...")
var shareContainerDiv *rod.Element
maxRetries := 5
retryDelay := 1000 // 每次重试间隔1秒
for attempt := 1; attempt <= maxRetries; attempt++ {
c.LogInfo(fmt.Sprintf("第 %d/%d 次尝试查找shareContainer...", attempt, maxRetries))
// 重新获取所有div元素
allDivs, err = c.Page.Elements("div")
if err != nil {
return "", fmt.Errorf("获取页面div元素失败: %v", err)
}
// 重新获取所有div元素
allDivs, err = c.Page.Elements("div")
if err != nil {
c.LogInfo(fmt.Sprintf("获取页面div元素失败: %v", err))
if attempt < maxRetries {
c.SleepMs(retryDelay)
continue
}
return "", fmt.Errorf("获取页面div元素失败: %v", err)
}
c.LogInfo(fmt.Sprintf("在 %d 个div元素中查找包含'shareContainer'的class", len(allDivs)))
c.LogInfo(fmt.Sprintf("在 %d 个div元素中查找包含'shareContainer'的class", len(allDivs)))
for _, elem := range allDivs {
classAttr, _ := elem.Attribute("class")
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "sharecontainer") {
tagName, _ := elem.Property("tagName")
c.LogInfo(fmt.Sprintf("✓ 找到shareContainer容器: tag=%s, class=%s", tagName.Str(), *classAttr))
shareContainerDiv = elem
break
}
}
if shareContainerDiv != nil {
break // 找到了,退出重试循环
}
// 没找到,等待后重试
if attempt < maxRetries {
c.LogInfo(fmt.Sprintf("未找到shareContainer%d毫秒后重试...", retryDelay))
c.SleepMs(retryDelay)
for _, elem := range allDivs {
classAttr, _ := elem.Attribute("class")
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "sharecontainer") {
tagName, _ := elem.Property("tagName")
c.LogInfo(fmt.Sprintf("✓ 找到shareContainer容器: tag=%s, class=%s", tagName.Str(), *classAttr))
shareContainerDiv = elem
break
}
}
if shareContainerDiv == nil {
c.Screenshot("share_container_not_found")
return "", fmt.Errorf("经过 %d 次重试仍未找到包含'shareContainer' class的div元素", maxRetries)
return "", fmt.Errorf("未找到包含'shareContainer' class的div元素")
}
// 步骤4: 在shareContainer内查找genLink的button(带重试机制)
// 步骤4: 在shareContainer内查找genLink的button
c.LogInfo("步骤4: 在shareContainer容器内查找包含'genLink'的button...")
var genLinkBtn *rod.Element
maxRetries = 3
retryDelay = 800
for attempt := 1; attempt <= maxRetries; attempt++ {
c.LogInfo(fmt.Sprintf("第 %d/%d 次尝试查找genLink按钮...", attempt, maxRetries))
buttons, err := shareContainerDiv.Elements("button")
if err != nil {
return "", fmt.Errorf("获取button元素失败: %v", err)
}
buttons, err := shareContainerDiv.Elements("button")
if err != nil {
c.LogInfo(fmt.Sprintf("获取button元素失败: %v", err))
if attempt < maxRetries {
c.SleepMs(retryDelay)
continue
}
return "", fmt.Errorf("获取button元素失败: %v", err)
}
c.LogInfo(fmt.Sprintf("在 %d 个button元素中查找包含'genLink'的class", len(buttons)))
c.LogInfo(fmt.Sprintf("在 %d 个button元素中查找包含'genLink'的class", len(buttons)))
for _, elem := range buttons {
classAttr, _ := elem.Attribute("class")
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "genlink") {
tagName, _ := elem.Property("tagName")
text, _ := elem.Text()
c.LogInfo(fmt.Sprintf("✓ 找到genLink按钮: tag=%s, class=%s, text=%s", tagName.Str(), *classAttr, strings.TrimSpace(text)))
genLinkBtn = elem
break
}
}
if genLinkBtn != nil {
break // 找到了,退出重试循环
}
// 没找到,等待后重试
if attempt < maxRetries {
c.LogInfo(fmt.Sprintf("未找到genLink按钮%d毫秒后重试...", retryDelay))
c.SleepMs(retryDelay)
for _, elem := range buttons {
classAttr, _ := elem.Attribute("class")
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "genlink") {
tagName, _ := elem.Property("tagName")
text, _ := elem.Text()
c.LogInfo(fmt.Sprintf("✓ 找到genLink按钮: tag=%s, class=%s, text=%s", tagName.Str(), *classAttr, strings.TrimSpace(text)))
genLinkBtn = elem
break
}
}
if genLinkBtn == nil {
c.Screenshot("genlink_button_not_found")
return "", fmt.Errorf("经过 %d 次重试仍未在shareContainer容器内找到包含'genLink' class的button", maxRetries)
return "", fmt.Errorf("在shareContainer容器内未找到包含'genLink' class的button")
}
// 滚动到按钮位置

View File

@ -153,13 +153,13 @@ func (c *CollectService) Collect(ctx *fiber.Ctx, req *entitys.ProductCollectRequ
return err
}
go c.doCollectAsync(collectCode, req.PlatformIndex, req.Question)
go c.doCollectAsync(collectCode, req.PlatformIndex, req.Question, req.Keywords)
return ctx.JSON(fiber.Map{"message": "收录生成中"})
}
// doCollectAsync 异步执行收集任务
func (c *CollectService) doCollectAsync(collectCode string, platforms []string, question string) {
func (c *CollectService) doCollectAsync(collectCode string, platforms []string, question string, keywords []string) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*240)
defer cancel()
defer func() {
@ -177,14 +177,14 @@ func (c *CollectService) doCollectAsync(collectCode string, platforms []string,
platformName, exist := collect.CollectorMap[platIndex]
if !exist {
log.Printf("未知的平台索引: %d", platIndex)
log.Printf("未知的平台索引: %s", platIndex)
return
}
requestID := fmt.Sprintf("%s_%s", collectCode, platIndex)
result, err := c.collectBiz.AskAIQuestion(platIndex, requestID, question, true)
result, err := c.collectBiz.AskAIQuestion(platIndex, requestID, question, keywords, true)
if err != nil {
log.Printf("平台 %s 收集失败: %v", platformName, err)
log.Printf("平台 %s 收集失败: %v", platformName.Name, err)
return
}
ise := 1

View File

@ -81,7 +81,7 @@ func TestWenxinCollector_AskQuestion(t *testing.T) {
// 设置收集参数
params := &collect.CollectParams{
Headless: false, // 显示浏览器以便调试
Headless: true, // 显示浏览器以便调试
RequestID: "test_wenxin_001",
Platform: "wenxin",
}