diff --git a/after_share_icon_click b/after_share_icon_click new file mode 100644 index 0000000..5ca27c1 Binary files /dev/null and b/after_share_icon_click differ diff --git a/deepseek_test.go b/deepseek_test.go new file mode 100644 index 0000000..a5c0290 --- /dev/null +++ b/deepseek_test.go @@ -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("答案为空") + } +} diff --git a/doubao_wait_answer_30 b/doubao_wait_answer_30 new file mode 100644 index 0000000..13aaa96 Binary files /dev/null and b/doubao_wait_answer_30 differ diff --git a/internal/biz/ai_collect.go b/internal/biz/ai_collect.go index 94d51d2..404cbd5 100644 --- a/internal/biz/ai_collect.go +++ b/internal/biz/ai_collect.go @@ -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 } diff --git a/internal/collect/README.md b/internal/collect/README.md index 1270715..20652ba 100644 --- a/internal/collect/README.md +++ b/internal/collect/README.md @@ -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个) + ↓ +为每个平台打开 Page(4个) + ├─ 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 ││ +│ └───────────────┘ └─────────┘│ +└─────────────────────────────────────┘ +``` + diff --git a/internal/collect/base.go b/internal/collect/base.go index b504924..e31ecc3 100644 --- a/internal/collect/base.go +++ b/internal/collect/base.go @@ -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 } diff --git a/internal/collect/deepseek.go b/internal/collect/deepseek.go index a0d5e92..89807bc 100644 --- a/internal/collect/deepseek.go +++ b/internal/collect/deepseek.go @@ -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 安全地获取元素 diff --git a/internal/collect/doubao.go b/internal/collect/doubao.go index 9220d35..f398a8b 100644 --- a/internal/collect/doubao.go +++ b/internal/collect/doubao.go @@ -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 { diff --git a/internal/collect/interface.go b/internal/collect/interface.go index 4a38b6a..fb374c9 100644 --- a/internal/collect/interface.go +++ b/internal/collect/interface.go @@ -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 { diff --git a/internal/collect/manager.go b/internal/collect/manager.go index 7a4daa9..cb0db8a 100644 --- a/internal/collect/manager.go +++ b/internal/collect/manager.go @@ -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 -} diff --git a/internal/collect/qianwen.go b/internal/collect/qianwen.go index d760e1f..77ee090 100644 --- a/internal/collect/qianwen.go +++ b/internal/collect/qianwen.go @@ -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) diff --git a/internal/collect/wenxin.go b/internal/collect/wenxin.go index efeaafb..2f67ae9 100644 --- a/internal/collect/wenxin.go +++ b/internal/collect/wenxin.go @@ -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") } // 滚动到按钮位置 diff --git a/internal/service/collect.go b/internal/service/collect.go index 31b999b..780c305 100644 --- a/internal/service/collect.go +++ b/internal/service/collect.go @@ -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 diff --git a/wenxin_test.go b/wenxin_test.go index 32990f3..380f6a6 100644 --- a/wenxin_test.go +++ b/wenxin_test.go @@ -81,7 +81,7 @@ func TestWenxinCollector_AskQuestion(t *testing.T) { // 设置收集参数 params := &collect.CollectParams{ - Headless: false, // 显示浏览器以便调试 + Headless: true, // 显示浏览器以便调试 RequestID: "test_wenxin_001", Platform: "wenxin", }