3232
This commit is contained in:
parent
9c22a1dc07
commit
aa4c7a09a9
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
|
|
@ -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("答案为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
|
|
@ -31,14 +31,8 @@ func NewCollectBiz(ctx context.Context, cfg *config.Config, logger log.AllLogger
|
||||||
// requestID: 请求ID
|
// requestID: 请求ID
|
||||||
// question: 问题内容
|
// question: 问题内容
|
||||||
// headless: 是否无头模式
|
// headless: 是否无头模式
|
||||||
func (b *CollectBiz) AskAIQuestion(platform string, requestID, question string, headless bool) (*collect.CollectResult, error) {
|
func (b *CollectBiz) AskAIQuestion(platform string, requestID, question string, keywords []string, headless bool) (*collect.CollectResult, error) {
|
||||||
params := &collect.CollectParams{
|
result, err := b.manager.AskQuestion(platform, question)
|
||||||
Headless: headless,
|
|
||||||
RequestID: requestID,
|
|
||||||
Platform: platform,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := b.manager.AskQuestion(platform, params, question)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("向%s提问失败: %w", platform, err)
|
return nil, fmt.Errorf("向%s提问失败: %w", platform, err)
|
||||||
}
|
}
|
||||||
|
|
@ -46,41 +40,11 @@ func (b *CollectBiz) AskAIQuestion(platform string, requestID, question string,
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitAILogin 等待AI平台登录
|
// WaitAILogin 等待AI平台登录 - 已废弃
|
||||||
func (b *CollectBiz) WaitAILogin(platform string, requestID string, headless bool) (bool, string) {
|
// 注意:新架构中不再需要单独的登录方法,登录状态通过 Cookie 自动维持
|
||||||
params := &collect.CollectParams{
|
|
||||||
Headless: headless,
|
|
||||||
RequestID: requestID,
|
|
||||||
Platform: platform,
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.manager.WaitLogin(platform, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAIPlatforms 列出所有支持的AI平台
|
// ListAIPlatforms 列出所有支持的AI平台
|
||||||
func (b *CollectBiz) ListAIPlatforms() []string {
|
func (b *CollectBiz) ListAIPlatforms() []string {
|
||||||
return b.manager.ListPlatforms()
|
platforms := []string{"wenxin", "deepseek", "doubao", "qianwen"}
|
||||||
}
|
return platforms
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
|
|
||||||
### 1. 基本使用
|
### 1. 基本使用
|
||||||
|
|
||||||
```go
|
``go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -89,7 +89,7 @@ func main() {
|
||||||
|
|
||||||
### 2. 多平台对比
|
### 2. 多平台对比
|
||||||
|
|
||||||
```go
|
``go
|
||||||
// 向多个AI平台提问同一个问题
|
// 向多个AI平台提问同一个问题
|
||||||
platforms := []string{"wenxin", "deepseek", "doubao", "qianwen"}
|
platforms := []string{"wenxin", "deepseek", "doubao", "qianwen"}
|
||||||
question := "什么是人工智能?"
|
question := "什么是人工智能?"
|
||||||
|
|
@ -115,7 +115,7 @@ for _, platform := range platforms {
|
||||||
|
|
||||||
### 3. 登录管理
|
### 3. 登录管理
|
||||||
|
|
||||||
```go
|
``go
|
||||||
// 首次使用时需要登录
|
// 首次使用时需要登录
|
||||||
params := &collect.CollectParams{
|
params := &collect.CollectParams{
|
||||||
Headless: false, // 显示浏览器窗口以便扫码登录
|
Headless: false, // 显示浏览器窗口以便扫码登录
|
||||||
|
|
@ -140,7 +140,7 @@ answer, _ := manager.AskQuestion("wenxin", params, "你好")
|
||||||
|
|
||||||
### 4. 列出支持的平台
|
### 4. 列出支持的平台
|
||||||
|
|
||||||
```go
|
``go
|
||||||
platforms := manager.ListPlatforms()
|
platforms := manager.ListPlatforms()
|
||||||
fmt.Printf("支持的平台: %v\n", platforms)
|
fmt.Printf("支持的平台: %v\n", platforms)
|
||||||
// 输出: 支持的平台: [wenxin deepseek doubao qianwen]
|
// 输出: 支持的平台: [wenxin deepseek doubao qianwen]
|
||||||
|
|
@ -150,7 +150,7 @@ fmt.Printf("支持的平台: %v\n", platforms)
|
||||||
|
|
||||||
### 必需的配置项
|
### 必需的配置项
|
||||||
|
|
||||||
```go
|
``go
|
||||||
type SysConfig struct {
|
type SysConfig struct {
|
||||||
ChromePath string // Chrome浏览器可执行文件路径
|
ChromePath string // Chrome浏览器可执行文件路径
|
||||||
ChromeDataDir string // Chrome用户数据目录
|
ChromeDataDir string // Chrome用户数据目录
|
||||||
|
|
@ -161,7 +161,7 @@ type SysConfig struct {
|
||||||
|
|
||||||
### 示例配置
|
### 示例配置
|
||||||
|
|
||||||
```go
|
``go
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Sys: config.SysConfig{
|
Sys: config.SysConfig{
|
||||||
ChromePath: "/usr/bin/google-chrome", // Linux
|
ChromePath: "/usr/bin/google-chrome", // Linux
|
||||||
|
|
@ -229,7 +229,7 @@ cfg := &config.Config{
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
```go
|
``go
|
||||||
package collect
|
package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -257,7 +257,7 @@ func NewNewPlatformCollector(ctx context.Context, params *CollectParams, cfg *co
|
||||||
|
|
||||||
然后在 `interface.go` 中注册:
|
然后在 `interface.go` 中注册:
|
||||||
|
|
||||||
```go
|
``go
|
||||||
var CollectorMap = map[string]*CollectorValue{
|
var CollectorMap = map[string]*CollectorValue{
|
||||||
// ... 其他平台
|
// ... 其他平台
|
||||||
"newplatform": {
|
"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 ││
|
||||||
|
│ └───────────────┘ └─────────┘│
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-rod/rod"
|
"github.com/go-rod/rod"
|
||||||
"github.com/go-rod/rod/lib/launcher"
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
@ -38,8 +37,8 @@ type BaseCollector struct {
|
||||||
RetryDelay int
|
RetryDelay int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBaseCollector 构造函数
|
// NewBaseCollector 构造函数 - 接收已有的 browser 和 page
|
||||||
func NewBaseCollector(ctx context.Context, params *CollectParams, config *config.Config, logger log.AllLogger) *BaseCollector {
|
func NewBaseCollector(ctx context.Context, params *CollectParams, config *config.Config, logger log.AllLogger, browser *rod.Browser, page *rod.Page) *BaseCollector {
|
||||||
var baseLogger log.AllLogger
|
var baseLogger log.AllLogger
|
||||||
|
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
|
|
@ -50,12 +49,14 @@ func NewBaseCollector(ctx context.Context, params *CollectParams, config *config
|
||||||
|
|
||||||
base := &BaseCollector{
|
base := &BaseCollector{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
Headless: params.Headless,
|
Headless: false, // 强制有头模式
|
||||||
RequestID: params.RequestID,
|
RequestID: params.RequestID,
|
||||||
Platform: params.Platform,
|
Platform: params.Platform,
|
||||||
KeyWords: params.KeyWords,
|
KeyWords: params.KeyWords,
|
||||||
Logger: baseLogger,
|
Logger: baseLogger,
|
||||||
config: config,
|
config: config,
|
||||||
|
Browser: browser,
|
||||||
|
Page: page,
|
||||||
MaxRetries: 3,
|
MaxRetries: 3,
|
||||||
RetryDelay: 200,
|
RetryDelay: 200,
|
||||||
}
|
}
|
||||||
|
|
@ -65,52 +66,22 @@ func NewBaseCollector(ctx context.Context, params *CollectParams, config *config
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupDriver 初始化浏览器驱动
|
// SetupDriver 初始化浏览器驱动 - 在常驻浏览器模式下,此方法不再需要创建浏览器
|
||||||
|
// 保留此方法仅为兼容性,实际不做任何操作
|
||||||
func (b *BaseCollector) SetupDriver() error {
|
func (b *BaseCollector) SetupDriver() error {
|
||||||
userDataDir := filepath.Join(b.config.Sys.ChromeDataDir, b.Platform, b.RequestID+fmt.Sprintf("___%d", time.Now().UnixNano()))
|
// 在常驻浏览器模式下,Browser 和 Page 已由 Manager 创建
|
||||||
os.MkdirAll(userDataDir, 0755)
|
if b.Browser == nil || b.Page == nil {
|
||||||
|
return fmt.Errorf("Browser 或 Page 未初始化")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close 关闭浏览器
|
// Close 关闭页面 - 如果是临时 Page,则关闭它
|
||||||
func (b *BaseCollector) Close() {
|
func (b *BaseCollector) Close() {
|
||||||
if b.Page != nil {
|
if b.Page != nil {
|
||||||
b.Page.Close()
|
b.Page.MustClose()
|
||||||
}
|
b.Page = nil
|
||||||
if b.Browser != nil {
|
b.LogInfo("🔒 临时 Page 已关闭")
|
||||||
b.Browser.Close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,13 +244,24 @@ func (b *BaseCollector) AskQuestion(question string) (*CollectResult, error) {
|
||||||
return nil, fmt.Errorf("需要实现")
|
return nil, fmt.Errorf("需要实现")
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitPage 初始化页面
|
// InitPage 初始化页面 - 如果 Page 为 nil,则创建临时 Page
|
||||||
func (b *BaseCollector) InitPage() error {
|
func (b *BaseCollector) InitPage() error {
|
||||||
|
// 如果 Page 为 nil,创建临时 Page
|
||||||
|
if b.Page == nil {
|
||||||
|
b.Page = b.Browser.MustPage()
|
||||||
|
b.LogInfo("✅ 创建临时 Page")
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试加载cookies
|
// 尝试加载cookies
|
||||||
if err := b.LoadCookies(); err == nil {
|
if err := b.LoadCookies(); err == nil {
|
||||||
b.Page.MustNavigate(b.ChatURL)
|
b.Page.MustNavigate(b.ChatURL)
|
||||||
b.WaitForPageReady(5)
|
b.WaitForPageReady(5)
|
||||||
|
} else {
|
||||||
|
// 如果没有 cookies,直接导航到聊天页面
|
||||||
|
b.Page.MustNavigate(b.ChatURL)
|
||||||
|
b.WaitForPageReady(5)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.SaveCookies()
|
b.SaveCookies()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"geo/internal/config"
|
"geo/internal/config"
|
||||||
|
"regexp"
|
||||||
"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"
|
||||||
|
|
@ -18,9 +20,9 @@ type DeepseekCollector struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDeepseekCollector 创建DeepSeek收集器
|
// 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{
|
collector := &DeepseekCollector{
|
||||||
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
|
BaseCollector: NewBaseCollector(ctx, params, cfg, logger, browser, page),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置DeepSeek的URL
|
// 设置DeepSeek的URL
|
||||||
|
|
@ -33,23 +35,12 @@ func NewDeepseekCollector(ctx context.Context, params *CollectParams, cfg *confi
|
||||||
// CheckLoginStatus 检查登录状态
|
// CheckLoginStatus 检查登录状态
|
||||||
func (c *DeepseekCollector) CheckLoginStatus() bool {
|
func (c *DeepseekCollector) CheckLoginStatus() bool {
|
||||||
currentURL := c.GetCurrentURL()
|
currentURL := c.GetCurrentURL()
|
||||||
|
c.LogInfo(fmt.Sprintf("当前URL: %s", currentURL))
|
||||||
// 如果在首页或登录页面,可能未登录
|
if currentURL == c.LoginURL {
|
||||||
if strings.Contains(currentURL, "chat.deepseek.com") {
|
return false
|
||||||
// 检查是否有用户头像或登录标识
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitLogin 等待登录
|
// WaitLogin 等待登录
|
||||||
|
|
@ -67,6 +58,9 @@ func (c *DeepseekCollector) WaitLogin() (bool, string) {
|
||||||
return true, "already_logged_in"
|
return true, "already_logged_in"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.LogInfo("未检测到登录状态,等待用户登录...")
|
||||||
|
|
||||||
|
// 最多等待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)
|
||||||
|
|
@ -81,10 +75,7 @@ func (c *DeepseekCollector) WaitLogin() (bool, string) {
|
||||||
|
|
||||||
// AskQuestion 提问并获取答案
|
// AskQuestion 提问并获取答案
|
||||||
func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error) {
|
func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error) {
|
||||||
if err := c.SetupDriver(); err != nil {
|
// 注意:SetupDriver 和 Close 已由 Manager 管理,这里不再调用
|
||||||
return nil, fmt.Errorf("浏览器启动失败: %v", err)
|
|
||||||
}
|
|
||||||
defer c.Close()
|
|
||||||
|
|
||||||
if err := c.InitPage(); err != nil {
|
if err := c.InitPage(); err != nil {
|
||||||
return nil, fmt.Errorf("页面初始化失败: %v", err)
|
return nil, fmt.Errorf("页面初始化失败: %v", err)
|
||||||
|
|
@ -105,14 +96,27 @@ func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error)
|
||||||
return nil, fmt.Errorf("获取答案失败: %v", err)
|
return nil, fmt.Errorf("获取答案失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关键词高亮处理
|
||||||
|
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
|
||||||
|
|
||||||
|
// 获取分享链接
|
||||||
|
shareLink := ""
|
||||||
|
link, _ := c.getShareLink()
|
||||||
|
if link != "" {
|
||||||
|
shareLink = link
|
||||||
|
}
|
||||||
|
|
||||||
return &CollectResult{
|
return &CollectResult{
|
||||||
Answer: answer,
|
Answer: answerStr,
|
||||||
ShareLink: "",
|
ShareLink: shareLink,
|
||||||
|
IsExposure: isExposure,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// inputQuestion 输入问题
|
// inputQuestion 输入问题
|
||||||
func (c *DeepseekCollector) inputQuestion(question string) error {
|
func (c *DeepseekCollector) inputQuestion(question string) error {
|
||||||
|
c.LogInfo("输入问题...")
|
||||||
|
|
||||||
// DeepSeek的输入框选择器
|
// DeepSeek的输入框选择器
|
||||||
inputSelectors := []string{
|
inputSelectors := []string{
|
||||||
"textarea[placeholder*='输入']",
|
"textarea[placeholder*='输入']",
|
||||||
|
|
@ -129,6 +133,7 @@ func (c *DeepseekCollector) 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,17 +148,12 @@ func (c *DeepseekCollector) inputQuestion(question string) error {
|
||||||
}
|
}
|
||||||
c.SleepMs(500)
|
c.SleepMs(500)
|
||||||
|
|
||||||
// 清空输入框
|
// fallback: 使用Focus + Input
|
||||||
if err := c.ClearInput(inputBox); err != nil {
|
inputBox.Focus()
|
||||||
// Ignore clear error
|
c.SleepMs(200)
|
||||||
}
|
inputBox.Input(question)
|
||||||
c.SleepMs(300)
|
|
||||||
|
|
||||||
// 输入问题
|
|
||||||
if err := c.SetInputValue(inputBox, question); err != nil {
|
|
||||||
inputBox.Input(question)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
c.LogInfo(fmt.Sprintf("问题已输入: %s", question))
|
||||||
c.SleepMs(1000)
|
c.SleepMs(1000)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -161,96 +161,349 @@ func (c *DeepseekCollector) inputQuestion(question string) error {
|
||||||
|
|
||||||
// clickSendButton 点击发送按钮
|
// clickSendButton 点击发送按钮
|
||||||
func (c *DeepseekCollector) clickSendButton() error {
|
func (c *DeepseekCollector) clickSendButton() error {
|
||||||
// 发送按钮选择器
|
c.LogInfo("点击发送按钮...")
|
||||||
sendSelectors := []string{
|
|
||||||
"button[class*='send']",
|
// 使用正则匹配包含"send"或"提交"的class
|
||||||
"button[class*='submit']",
|
allElements, err := c.Page.Elements("*")
|
||||||
".send-button",
|
if err != nil {
|
||||||
".submit-button",
|
return fmt.Errorf("获取页面元素失败: %v", err)
|
||||||
"button svg[path*='send']",
|
|
||||||
"[aria-label*='发送']",
|
|
||||||
"[aria-label*='Send']",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var sendBtn *rod.Element
|
var sendBtn *rod.Element
|
||||||
var err error
|
for _, elem := range allElements {
|
||||||
|
classAttr, _ := elem.Attribute("class")
|
||||||
for _, selector := range sendSelectors {
|
if classAttr != nil {
|
||||||
sendBtn, err = c.WaitForElementClickable(selector, 5)
|
classLower := strings.ToLower(*classAttr)
|
||||||
if err == nil && sendBtn != nil {
|
if strings.Contains(classLower, "send") || strings.Contains(classLower, "submit") {
|
||||||
break
|
// 检查是否是可点击的元素(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 {
|
if sendBtn == nil {
|
||||||
// 尝试查找发送图标
|
// fallback: 尝试查找发送图标或最后一个button
|
||||||
sendBtn, err = c.Page.Element("button svg")
|
buttons, _ := c.Page.Elements("button")
|
||||||
if err != nil {
|
if len(buttons) > 0 {
|
||||||
return fmt.Errorf("未找到发送按钮")
|
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)
|
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)
|
return fmt.Errorf("点击发送按钮失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.LogInfo("已点击发送按钮")
|
||||||
c.SleepMs(2000)
|
c.SleepMs(2000)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForAnswer 等待并获取答案
|
// waitForAnswer 等待并获取答案(处理流式输出)
|
||||||
func (c *DeepseekCollector) waitForAnswer() (string, error) {
|
func (c *DeepseekCollector) waitForAnswer() (string, error) {
|
||||||
timeout := 120 // 最大等待时间(秒)
|
c.LogInfo("等待AI回答...")
|
||||||
|
|
||||||
|
timeout := 180 // 最大等待时间(秒)
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
lastAnswerLength := 0
|
|
||||||
|
var lastAnswer string
|
||||||
|
var stableCount int
|
||||||
|
const requiredStableCount = 5 // 需要连续5次内容不变才认为完成
|
||||||
|
isAnswering := false
|
||||||
|
|
||||||
for time.Since(startTime).Seconds() < float64(timeout) {
|
for time.Since(startTime).Seconds() < float64(timeout) {
|
||||||
// 查找答案区域
|
// 查找答案区域 - 尝试多种选择器
|
||||||
answerSelectors := []string{
|
answerSelectors := []string{
|
||||||
".message-content",
|
".message-content",
|
||||||
".response-content",
|
".response-content",
|
||||||
"[class*='assistant'] [class*='content']",
|
"[class*='assistant'] [class*='content']",
|
||||||
"[class*='ai'] [class*='message']",
|
"[class*='ai'] [class*='message']",
|
||||||
".chat-message.ai",
|
".chat-message.ai",
|
||||||
|
"[class*='answer']",
|
||||||
|
"[class*='response']",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var answerText string
|
||||||
|
var foundAnswer bool
|
||||||
|
|
||||||
for _, selector := range answerSelectors {
|
for _, selector := range answerSelectors {
|
||||||
answerElements, err := c.Page.Elements(selector)
|
answerElements, err := c.Page.Elements(selector)
|
||||||
if err == nil && len(answerElements) > 0 {
|
if err == nil && len(answerElements) > 0 {
|
||||||
// 获取最后一个答案元素
|
// 获取最后一个答案元素(最新的回答)
|
||||||
lastAnswer := answerElements[len(answerElements)-1]
|
lastAnswer := answerElements[len(answerElements)-1]
|
||||||
|
|
||||||
visible, _ := lastAnswer.Visible()
|
visible, _ := lastAnswer.Visible()
|
||||||
if visible {
|
if visible {
|
||||||
text, err := lastAnswer.Text()
|
// 尝试获取HTML内容
|
||||||
if err == nil && len(strings.TrimSpace(text)) > 0 {
|
htmlContent, err := lastAnswer.HTML()
|
||||||
// 检查是否正在生成
|
if err == nil && len(strings.TrimSpace(htmlContent)) > 30 {
|
||||||
isGenerating := strings.Contains(text, "正在") ||
|
answerText = CleanDivTags(htmlContent)
|
||||||
strings.Contains(text, "思考") ||
|
foundAnswer = true
|
||||||
strings.Contains(text, "generating")
|
c.LogInfo(fmt.Sprintf("找到答案(%s),清理后文本长度: %d", selector, len(answerText)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
if !isGenerating {
|
// 如果HTML获取失败,尝试获取文本
|
||||||
// 检查答案是否还在增长
|
textContent, _ := lastAnswer.Text()
|
||||||
currentLength := len(text)
|
if len(strings.TrimSpace(textContent)) > 30 {
|
||||||
if currentLength == lastAnswerLength && currentLength > 10 {
|
answerText = strings.TrimSpace(textContent)
|
||||||
// 答案不再增长,认为已完成
|
foundAnswer = true
|
||||||
return strings.TrimSpace(text), nil
|
c.LogInfo(fmt.Sprintf("找到答案(%s),文本长度: %d", selector, len(answerText)))
|
||||||
}
|
break
|
||||||
lastAnswerLength = currentLength
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 安全地获取元素
|
// SafeElement 安全地获取元素
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ type DoubaoCollector struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDoubaoCollector 创建豆包收集器
|
// 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{
|
collector := &DoubaoCollector{
|
||||||
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
|
BaseCollector: NewBaseCollector(ctx, params, cfg, logger, browser, page),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置豆包的URL
|
// 设置豆包的URL
|
||||||
|
|
@ -98,20 +98,10 @@ func (c *DoubaoCollector) WaitLogin() (bool, string) {
|
||||||
|
|
||||||
// AskQuestion 提问并获取答案
|
// AskQuestion 提问并获取答案
|
||||||
func (c *DoubaoCollector) AskQuestion(question string) (*CollectResult, error) {
|
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 {
|
if err := c.InitPage(); err != nil {
|
||||||
return nil, fmt.Errorf("页面初始化失败: %v", err)
|
return nil, fmt.Errorf("页面初始化失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否登录
|
|
||||||
if !c.CheckLoginStatus() {
|
|
||||||
return nil, fmt.Errorf("未登录,请先调用WaitLogin进行登录")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.LogInfo(fmt.Sprintf("开始提问: %s", question))
|
c.LogInfo(fmt.Sprintf("开始提问: %s", question))
|
||||||
|
|
||||||
if err := c.inputQuestion(question); err != nil {
|
if err := c.inputQuestion(question); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,16 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"geo/internal/config"
|
"geo/internal/config"
|
||||||
|
|
||||||
|
"github.com/go-rod/rod"
|
||||||
"github.com/gofiber/fiber/v2/log"
|
"github.com/gofiber/fiber/v2/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CollectorInterface AI平台收集器接口
|
// CollectorInterface AI平台收集器接口
|
||||||
type CollectorInterface interface {
|
type CollectorInterface interface {
|
||||||
// WaitLogin 等待登录
|
|
||||||
WaitLogin() (bool, string)
|
|
||||||
// AskQuestion 提问并获取答案
|
// AskQuestion 提问并获取答案
|
||||||
AskQuestion(question string) (*CollectResult, error)
|
AskQuestion(question string) (*CollectResult, error)
|
||||||
|
// Close 关闭页面(释放资源)
|
||||||
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CollectResult 收集结果
|
// CollectResult 收集结果
|
||||||
|
|
@ -27,7 +28,9 @@ type NewCollector func(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
param *CollectParams,
|
param *CollectParams,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
logger log.AllLogger) CollectorInterface
|
logger log.AllLogger,
|
||||||
|
browser *rod.Browser,
|
||||||
|
page *rod.Page) CollectorInterface
|
||||||
|
|
||||||
// CollectorValue 收集器配置信息
|
// CollectorValue 收集器配置信息
|
||||||
type CollectorValue struct {
|
type CollectorValue struct {
|
||||||
|
|
|
||||||
|
|
@ -4,66 +4,129 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"geo/internal/config"
|
"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"
|
"github.com/gofiber/fiber/v2/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CollectManager 收集管理器
|
// CollectManager 收集管理器 - 单浏览器临时Page模式
|
||||||
type CollectManager struct {
|
type CollectManager struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
config *config.Config
|
config *config.Config
|
||||||
logger log.AllLogger
|
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 {
|
func NewCollectManager(ctx context.Context, cfg *config.Config, logger log.AllLogger) *CollectManager {
|
||||||
return &CollectManager{
|
manager := &CollectManager{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
collectors: make(map[string]CollectorInterface),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注册所有平台的 Collector
|
||||||
|
manager.registerCollectors()
|
||||||
|
|
||||||
|
// 服务启动时立即创建全局浏览器
|
||||||
|
manager.initBrowser()
|
||||||
|
|
||||||
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCollector 获取指定平台的收集器
|
// initBrowser 初始化全局浏览器实例
|
||||||
func (m *CollectManager) GetCollector(platform string, params *CollectParams) (CollectorInterface, error) {
|
func (m *CollectManager) initBrowser() {
|
||||||
collectorValue, ok := CollectorMap[platform]
|
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 {
|
if !ok {
|
||||||
return nil, fmt.Errorf("不支持的平台: %s", platform)
|
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
|
return collector, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AskQuestion 向指定AI平台提问
|
// AskQuestion 向指定平台提问(每次创建临时 Page)
|
||||||
func (m *CollectManager) AskQuestion(platform string, params *CollectParams, question string) (*CollectResult, error) {
|
func (m *CollectManager) AskQuestion(platform string, question string) (*CollectResult, error) {
|
||||||
collector, err := m.GetCollector(platform, params)
|
collector, err := m.GetCollector(platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return collector.AskQuestion(question)
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ type QianwenCollector struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQianwenCollector 创建通义千问收集器
|
// 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{
|
collector := &QianwenCollector{
|
||||||
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
|
BaseCollector: NewBaseCollector(ctx, params, cfg, logger, browser, page),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置通义千问的URL
|
// 设置通义千问的URL
|
||||||
|
|
@ -81,10 +81,7 @@ func (c *QianwenCollector) WaitLogin() (bool, string) {
|
||||||
|
|
||||||
// AskQuestion 提问并获取答案
|
// AskQuestion 提问并获取答案
|
||||||
func (c *QianwenCollector) AskQuestion(question string) (*CollectResult, error) {
|
func (c *QianwenCollector) AskQuestion(question string) (*CollectResult, error) {
|
||||||
if err := c.SetupDriver(); err != nil {
|
// 注意:SetupDriver 和 Close 已由 Manager 管理,这里不再调用
|
||||||
return nil, fmt.Errorf("浏览器启动失败: %v", err)
|
|
||||||
}
|
|
||||||
defer c.Close()
|
|
||||||
|
|
||||||
if err := c.InitPage(); err != nil {
|
if err := c.InitPage(); err != nil {
|
||||||
return nil, fmt.Errorf("页面初始化失败: %v", err)
|
return nil, fmt.Errorf("页面初始化失败: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@ 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"
|
||||||
|
|
@ -30,9 +29,9 @@ type WenxinCollector struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWenxinCollector 创建文心一言收集器
|
// 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{
|
collector := &WenxinCollector{
|
||||||
BaseCollector: NewBaseCollector(ctx, params, cfg, logger),
|
BaseCollector: NewBaseCollector(ctx, params, cfg, logger, browser, page),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置文心一言的URL
|
// 设置文心一言的URL
|
||||||
|
|
@ -101,15 +100,14 @@ func (c *WenxinCollector) WaitLogin() (bool, string) {
|
||||||
|
|
||||||
// AskQuestion 提问并获取答案
|
// AskQuestion 提问并获取答案
|
||||||
func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
|
func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
|
||||||
if err := c.SetupDriver(); err != nil {
|
// 注意:SetupDriver 和 Close 已由 Manager 管理,这里不再调用
|
||||||
return nil, fmt.Errorf("浏览器启动失败: %v", err)
|
|
||||||
}
|
|
||||||
defer c.Close()
|
|
||||||
|
|
||||||
if err := c.InitPage(); err != nil {
|
if err := c.InitPage(); err != nil {
|
||||||
return nil, fmt.Errorf("页面初始化失败: %v", err)
|
return nil, fmt.Errorf("页面初始化失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Sleep(3)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +120,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()
|
||||||
|
|
@ -131,9 +129,8 @@ func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CollectResult{
|
return &CollectResult{
|
||||||
Answer: answerStr,
|
Answer: answer,
|
||||||
ShareLink: shareLink,
|
ShareLink: shareLink,
|
||||||
IsExposure: isExposure,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +142,7 @@ func (c *WenxinCollector) inputQuestion(question string) error {
|
||||||
inputSelectors := []string{
|
inputSelectors := []string{
|
||||||
"[contenteditable='true']",
|
"[contenteditable='true']",
|
||||||
"div[contenteditable]",
|
"div[contenteditable]",
|
||||||
|
".editable__T7WAW4uW",
|
||||||
"[class*='editable']",
|
"[class*='editable']",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,105 +481,61 @@ func (c *WenxinCollector) getShareLink() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
c.LogInfo("✓ 点击成功")
|
c.LogInfo("✓ 点击成功")
|
||||||
c.SleepMs(3000) // 等待弹窗出现
|
c.SleepMs(2000) // 等待弹窗出现
|
||||||
c.Screenshot("after_share_icon_click")
|
c.Screenshot("after_share_icon_click")
|
||||||
|
|
||||||
// 步骤3: 在弹窗中查找shareContainer的div(带重试机制)
|
// 步骤3: 在弹窗中查找shareContainer的div
|
||||||
c.LogInfo("步骤3: 查找包含'shareContainer'的div元素...")
|
c.LogInfo("步骤3: 查找包含'shareContainer'的div元素...")
|
||||||
|
|
||||||
var shareContainerDiv *rod.Element
|
var shareContainerDiv *rod.Element
|
||||||
maxRetries := 5
|
|
||||||
retryDelay := 1000 // 每次重试间隔1秒
|
|
||||||
|
|
||||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
// 重新获取所有div元素
|
||||||
c.LogInfo(fmt.Sprintf("第 %d/%d 次尝试查找shareContainer...", attempt, maxRetries))
|
allDivs, err = c.Page.Elements("div")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取页面div元素失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 重新获取所有div元素
|
c.LogInfo(fmt.Sprintf("在 %d 个div元素中查找包含'shareContainer'的class", len(allDivs)))
|
||||||
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)))
|
for _, elem := range allDivs {
|
||||||
|
classAttr, _ := elem.Attribute("class")
|
||||||
for _, elem := range allDivs {
|
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "sharecontainer") {
|
||||||
classAttr, _ := elem.Attribute("class")
|
tagName, _ := elem.Property("tagName")
|
||||||
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "sharecontainer") {
|
c.LogInfo(fmt.Sprintf("✓ 找到shareContainer容器: tag=%s, class=%s", tagName.Str(), *classAttr))
|
||||||
tagName, _ := elem.Property("tagName")
|
shareContainerDiv = elem
|
||||||
c.LogInfo(fmt.Sprintf("✓ 找到shareContainer容器: tag=%s, class=%s", tagName.Str(), *classAttr))
|
break
|
||||||
shareContainerDiv = elem
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if shareContainerDiv != nil {
|
|
||||||
break // 找到了,退出重试循环
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没找到,等待后重试
|
|
||||||
if attempt < maxRetries {
|
|
||||||
c.LogInfo(fmt.Sprintf("未找到shareContainer,%d毫秒后重试...", retryDelay))
|
|
||||||
c.SleepMs(retryDelay)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if shareContainerDiv == nil {
|
if shareContainerDiv == nil {
|
||||||
c.Screenshot("share_container_not_found")
|
return "", fmt.Errorf("未找到包含'shareContainer' class的div元素")
|
||||||
return "", fmt.Errorf("经过 %d 次重试仍未找到包含'shareContainer' class的div元素", maxRetries)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 步骤4: 在shareContainer内查找genLink的button(带重试机制)
|
// 步骤4: 在shareContainer内查找genLink的button
|
||||||
c.LogInfo("步骤4: 在shareContainer容器内查找包含'genLink'的button...")
|
c.LogInfo("步骤4: 在shareContainer容器内查找包含'genLink'的button...")
|
||||||
|
|
||||||
var genLinkBtn *rod.Element
|
var genLinkBtn *rod.Element
|
||||||
maxRetries = 3
|
|
||||||
retryDelay = 800
|
|
||||||
|
|
||||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
buttons, err := shareContainerDiv.Elements("button")
|
||||||
c.LogInfo(fmt.Sprintf("第 %d/%d 次尝试查找genLink按钮...", attempt, maxRetries))
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取button元素失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
buttons, err := shareContainerDiv.Elements("button")
|
c.LogInfo(fmt.Sprintf("在 %d 个button元素中查找包含'genLink'的class", len(buttons)))
|
||||||
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)))
|
for _, elem := range buttons {
|
||||||
|
classAttr, _ := elem.Attribute("class")
|
||||||
for _, elem := range buttons {
|
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "genlink") {
|
||||||
classAttr, _ := elem.Attribute("class")
|
tagName, _ := elem.Property("tagName")
|
||||||
if classAttr != nil && strings.Contains(strings.ToLower(*classAttr), "genlink") {
|
text, _ := elem.Text()
|
||||||
tagName, _ := elem.Property("tagName")
|
c.LogInfo(fmt.Sprintf("✓ 找到genLink按钮: tag=%s, class=%s, text=%s", tagName.Str(), *classAttr, strings.TrimSpace(text)))
|
||||||
text, _ := elem.Text()
|
genLinkBtn = elem
|
||||||
c.LogInfo(fmt.Sprintf("✓ 找到genLink按钮: tag=%s, class=%s, text=%s", tagName.Str(), *classAttr, strings.TrimSpace(text)))
|
break
|
||||||
genLinkBtn = elem
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if genLinkBtn != nil {
|
|
||||||
break // 找到了,退出重试循环
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没找到,等待后重试
|
|
||||||
if attempt < maxRetries {
|
|
||||||
c.LogInfo(fmt.Sprintf("未找到genLink按钮,%d毫秒后重试...", retryDelay))
|
|
||||||
c.SleepMs(retryDelay)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if genLinkBtn == nil {
|
if genLinkBtn == nil {
|
||||||
c.Screenshot("genlink_button_not_found")
|
return "", fmt.Errorf("在shareContainer容器内未找到包含'genLink' class的button")
|
||||||
return "", fmt.Errorf("经过 %d 次重试仍未在shareContainer容器内找到包含'genLink' class的button", maxRetries)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动到按钮位置
|
// 滚动到按钮位置
|
||||||
|
|
|
||||||
|
|
@ -153,13 +153,13 @@ func (c *CollectService) Collect(ctx *fiber.Ctx, req *entitys.ProductCollectRequ
|
||||||
return err
|
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": "收录生成中"})
|
return ctx.JSON(fiber.Map{"message": "收录生成中"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// doCollectAsync 异步执行收集任务
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*240)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -177,14 +177,14 @@ func (c *CollectService) doCollectAsync(collectCode string, platforms []string,
|
||||||
|
|
||||||
platformName, exist := collect.CollectorMap[platIndex]
|
platformName, exist := collect.CollectorMap[platIndex]
|
||||||
if !exist {
|
if !exist {
|
||||||
log.Printf("未知的平台索引: %d", platIndex)
|
log.Printf("未知的平台索引: %s", platIndex)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestID := fmt.Sprintf("%s_%s", collectCode, platIndex)
|
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 {
|
if err != nil {
|
||||||
log.Printf("平台 %s 收集失败: %v", platformName, err)
|
log.Printf("平台 %s 收集失败: %v", platformName.Name, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ise := 1
|
ise := 1
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ func TestWenxinCollector_AskQuestion(t *testing.T) {
|
||||||
|
|
||||||
// 设置收集参数
|
// 设置收集参数
|
||||||
params := &collect.CollectParams{
|
params := &collect.CollectParams{
|
||||||
Headless: false, // 显示浏览器以便调试
|
Headless: true, // 显示浏览器以便调试
|
||||||
RequestID: "test_wenxin_001",
|
RequestID: "test_wenxin_001",
|
||||||
Platform: "wenxin",
|
Platform: "wenxin",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue