3232
This commit is contained in:
parent
9c22a1dc07
commit
2a1a3d4418
|
|
@ -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 ││
|
||||||
|
│ └───────────────┘ └─────────┘│
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,7 @@ func (b *BaseCollector) InitPage() error {
|
||||||
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)
|
||||||
|
b.Sleep(3)
|
||||||
}
|
}
|
||||||
b.SaveCookies()
|
b.SaveCookies()
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package collect
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"geo/internal/config"
|
"geo/internal/config"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -30,6 +32,134 @@ func NewDeepseekCollector(ctx context.Context, params *CollectParams, cfg *confi
|
||||||
return collector
|
return collector
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveLocalStorage 保存LocalStorage数据
|
||||||
|
func (c *DeepseekCollector) saveLocalStorage() error {
|
||||||
|
// 使用JavaScript获取所有LocalStorage数据
|
||||||
|
result, err := c.Page.Eval(`() => {
|
||||||
|
const data = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
data[key] = localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取LocalStorage失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到文件
|
||||||
|
localStorageFile := c.CookiesFile[:len(c.CookiesFile)-5] + "_localstorage.json"
|
||||||
|
return os.WriteFile(localStorageFile, []byte(result.Value.Str()), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadLocalStorage 加载LocalStorage数据
|
||||||
|
func (c *DeepseekCollector) loadLocalStorage() error {
|
||||||
|
localStorageFile := c.CookiesFile[:len(c.CookiesFile)-5] + "_localstorage.json"
|
||||||
|
|
||||||
|
data, err := os.ReadFile(localStorageFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var storageData map[string]string
|
||||||
|
if err := json.Unmarshal(data, &storageData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用JavaScript设置LocalStorage
|
||||||
|
for key, value := range storageData {
|
||||||
|
_, err := c.Page.Eval(`(key, val) => localStorage.setItem(key, val)`, key, value)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger.Warnf("设置LocalStorage键 %s 失败: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveSessionStorage 保存SessionStorage数据
|
||||||
|
func (c *DeepseekCollector) saveSessionStorage() error {
|
||||||
|
result, err := c.Page.Eval(`() => {
|
||||||
|
const data = {};
|
||||||
|
for (let i = 0; i < sessionStorage.length; i++) {
|
||||||
|
const key = sessionStorage.key(i);
|
||||||
|
data[key] = sessionStorage.getItem(key);
|
||||||
|
}
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取SessionStorage失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorageFile := c.CookiesFile[:len(c.CookiesFile)-5] + "_sessionstorage.json"
|
||||||
|
return os.WriteFile(sessionStorageFile, []byte(result.Value.Str()), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSessionStorage 加载SessionStorage数据
|
||||||
|
func (c *DeepseekCollector) loadSessionStorage() error {
|
||||||
|
sessionStorageFile := c.CookiesFile[:len(c.CookiesFile)-5] + "_sessionstorage.json"
|
||||||
|
|
||||||
|
data, err := os.ReadFile(sessionStorageFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var storageData map[string]string
|
||||||
|
if err := json.Unmarshal(data, &storageData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range storageData {
|
||||||
|
_, err := c.Page.Eval(`(key, val) => sessionStorage.setItem(key, val)`, key, value)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger.Warnf("设置SessionStorage键 %s 失败: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveBrowserStorage 保存所有浏览器存储(Cookies + LocalStorage + SessionStorage)
|
||||||
|
func (c *DeepseekCollector) SaveBrowserStorage() error {
|
||||||
|
// 保存Cookies
|
||||||
|
if err := c.SaveCookies(); err != nil {
|
||||||
|
c.Logger.Warnf("保存Cookies失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存LocalStorage
|
||||||
|
if err := c.saveLocalStorage(); err != nil {
|
||||||
|
c.Logger.Warnf("保存LocalStorage失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存SessionStorage
|
||||||
|
if err := c.saveSessionStorage(); err != nil {
|
||||||
|
c.Logger.Warnf("保存SessionStorage失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBrowserStorage 加载所有浏览器存储
|
||||||
|
func (c *DeepseekCollector) LoadBrowserStorage() error {
|
||||||
|
// 加载Cookies
|
||||||
|
if err := c.LoadCookies(); err != nil {
|
||||||
|
c.Logger.Warnf("加载Cookies失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载LocalStorage
|
||||||
|
if err := c.loadLocalStorage(); err != nil {
|
||||||
|
c.Logger.Warnf("加载LocalStorage失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载SessionStorage
|
||||||
|
if err := c.loadSessionStorage(); err != nil {
|
||||||
|
c.Logger.Warnf("加载SessionStorage失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CheckLoginStatus 检查登录状态
|
// CheckLoginStatus 检查登录状态
|
||||||
func (c *DeepseekCollector) CheckLoginStatus() bool {
|
func (c *DeepseekCollector) CheckLoginStatus() bool {
|
||||||
currentURL := c.GetCurrentURL()
|
currentURL := c.GetCurrentURL()
|
||||||
|
|
@ -63,14 +193,14 @@ func (c *DeepseekCollector) WaitLogin() (bool, string) {
|
||||||
c.Sleep(3)
|
c.Sleep(3)
|
||||||
|
|
||||||
if c.CheckLoginStatus() {
|
if c.CheckLoginStatus() {
|
||||||
c.SaveCookies()
|
c.SaveBrowserStorage()
|
||||||
return true, "already_logged_in"
|
return true, "already_logged_in"
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 300; i++ {
|
for i := 0; i < 300; i++ {
|
||||||
if c.CheckLoginStatus() {
|
if c.CheckLoginStatus() {
|
||||||
c.Sleep(2)
|
c.Sleep(2)
|
||||||
c.SaveCookies()
|
c.SaveBrowserStorage()
|
||||||
return true, "login_success"
|
return true, "login_success"
|
||||||
}
|
}
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
@ -79,6 +209,25 @@ func (c *DeepseekCollector) WaitLogin() (bool, string) {
|
||||||
return false, "登录超时"
|
return false, "登录超时"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitPage 初始化页面(重写基类方法以支持LocalStorage)
|
||||||
|
func (c *DeepseekCollector) InitPage() error {
|
||||||
|
// 先导航到页面
|
||||||
|
c.Page.MustNavigate(c.ChatURL)
|
||||||
|
c.WaitForPageReady(5)
|
||||||
|
|
||||||
|
// 然后尝试加载浏览器存储(Cookies + LocalStorage + SessionStorage)
|
||||||
|
if err := c.LoadBrowserStorage(); err == nil {
|
||||||
|
c.LogInfo("已加载浏览器存储")
|
||||||
|
// 重新加载页面以应用存储的数据
|
||||||
|
c.Page.MustReload()
|
||||||
|
c.WaitForPageReady(5)
|
||||||
|
} else {
|
||||||
|
c.LogInfo("未找到保存的浏览器存储")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// AskQuestion 提问并获取答案
|
// AskQuestion 提问并获取答案
|
||||||
func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error) {
|
func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error) {
|
||||||
if err := c.SetupDriver(); err != nil {
|
if err := c.SetupDriver(); err != nil {
|
||||||
|
|
@ -90,8 +239,6 @@ func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -115,12 +262,7 @@ func (c *DeepseekCollector) AskQuestion(question string) (*CollectResult, error)
|
||||||
func (c *DeepseekCollector) inputQuestion(question string) error {
|
func (c *DeepseekCollector) inputQuestion(question string) error {
|
||||||
// DeepSeek的输入框选择器
|
// DeepSeek的输入框选择器
|
||||||
inputSelectors := []string{
|
inputSelectors := []string{
|
||||||
"textarea[placeholder*='输入']",
|
"textarea[placeholder*='Message DeepSeek']",
|
||||||
"textarea[placeholder*='问']",
|
|
||||||
"textarea",
|
|
||||||
"[contenteditable='true']",
|
|
||||||
".chat-input textarea",
|
|
||||||
"#message-input",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var inputBox *rod.Element
|
var inputBox *rod.Element
|
||||||
|
|
@ -161,40 +303,55 @@ func (c *DeepseekCollector) inputQuestion(question string) error {
|
||||||
|
|
||||||
// clickSendButton 点击发送按钮
|
// clickSendButton 点击发送按钮
|
||||||
func (c *DeepseekCollector) clickSendButton() error {
|
func (c *DeepseekCollector) clickSendButton() error {
|
||||||
// 发送按钮选择器
|
// 使用JavaScript直接找到input的父级下的第三个div并点击
|
||||||
sendSelectors := []string{
|
clickJS := `
|
||||||
"button[class*='send']",
|
() => {
|
||||||
"button[class*='submit']",
|
// 找到页面上第一个input元素
|
||||||
".send-button",
|
const input = document.querySelector('input');
|
||||||
".submit-button",
|
if (!input) {
|
||||||
"button svg[path*='send']",
|
return { success: false, error: '未找到input元素', divCount: 0 };
|
||||||
"[aria-label*='发送']",
|
|
||||||
"[aria-label*='Send']",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var sendBtn *rod.Element
|
// 获取input的父级元素
|
||||||
var err error
|
const parent = input.parentElement;
|
||||||
|
if (!parent) {
|
||||||
for _, selector := range sendSelectors {
|
return { success: false, error: '未找到input的父级元素', divCount: 0 };
|
||||||
sendBtn, err = c.WaitForElementClickable(selector, 5)
|
|
||||||
if err == nil && sendBtn != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sendBtn == nil {
|
// 找到父级下的直接子级div元素(只找一级)
|
||||||
// 尝试查找发送图标
|
const divs = parent.querySelectorAll(':scope > div');
|
||||||
sendBtn, err = c.Page.Element("button svg")
|
const divCount = divs.length;
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("未找到发送按钮")
|
if (divs.length < 2) {
|
||||||
}
|
return { success: false, error: '父级下没有足够的直接子级div元素', divCount: divCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SleepMs(500)
|
// 获取第2个div作为发送按钮
|
||||||
|
const sendBtn = divs[1];
|
||||||
|
const s = sendBtn.querySelectorAll(':scope > div');
|
||||||
|
console.log(s.length);
|
||||||
|
console.log('开始点击');
|
||||||
// 点击发送按钮
|
// 点击发送按钮
|
||||||
if err := c.JSClick(sendBtn); err != nil {
|
s[0].click();
|
||||||
return fmt.Errorf("点击发送按钮失败: %v", err)
|
console.log('开始完成');
|
||||||
|
return { success: true, divCount: divCount };
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := c.Page.Eval(clickJS)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("执行点击JavaScript失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查执行结果
|
||||||
|
success := result.Value.Get("success").Bool()
|
||||||
|
divCount := result.Value.Get("divCount").Int()
|
||||||
|
|
||||||
|
c.LogInfof("父级下共有 %d 个直接子级div元素", divCount)
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
errorMsg := result.Value.Get("error").String()
|
||||||
|
return fmt.Errorf("点击发送按钮失败: %s", errorMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SleepMs(2000)
|
c.SleepMs(2000)
|
||||||
|
|
@ -211,11 +368,7 @@ func (c *DeepseekCollector) waitForAnswer() (string, error) {
|
||||||
for time.Since(startTime).Seconds() < float64(timeout) {
|
for time.Since(startTime).Seconds() < float64(timeout) {
|
||||||
// 查找答案区域
|
// 查找答案区域
|
||||||
answerSelectors := []string{
|
answerSelectors := []string{
|
||||||
".message-content",
|
"div[class='ds-markdown']",
|
||||||
".response-content",
|
|
||||||
"[class*='assistant'] [class*='content']",
|
|
||||||
"[class*='ai'] [class*='message']",
|
|
||||||
".chat-message.ai",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, selector := range answerSelectors {
|
for _, selector := range answerSelectors {
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,9 @@ func (c *DoubaoCollector) AskQuestion(question string) (*CollectResult, error) {
|
||||||
}
|
}
|
||||||
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
|
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
|
||||||
|
|
||||||
// 获取分享链接
|
//// 获取分享链接
|
||||||
shareLink := c.getShareLink()
|
shareLink := ""
|
||||||
|
//shareLink := c.getShareLink()
|
||||||
|
|
||||||
c.LogInfo(fmt.Sprintf("✓ 获取答案成功,长度: %d 字符", len(answer)))
|
c.LogInfo(fmt.Sprintf("✓ 获取答案成功,长度: %d 字符", len(answer)))
|
||||||
|
|
||||||
|
|
@ -189,34 +190,10 @@ func (c *DoubaoCollector) inputQuestion(question string) error {
|
||||||
func (c *DoubaoCollector) clickSendButton() error {
|
func (c *DoubaoCollector) clickSendButton() error {
|
||||||
c.LogInfo("点击发送按钮...")
|
c.LogInfo("点击发送按钮...")
|
||||||
|
|
||||||
// 尝试多种方式查找发送按钮
|
|
||||||
sendSelectors := []string{
|
|
||||||
"button[class*='send']",
|
|
||||||
"button[class*='submit']",
|
|
||||||
".send-btn",
|
|
||||||
".submit-btn",
|
|
||||||
"[aria-label*='发送']",
|
|
||||||
"[aria-label*='send']",
|
|
||||||
".send-icon",
|
|
||||||
"button svg[path*='send']",
|
|
||||||
"button svg[path*='arrow']",
|
|
||||||
}
|
|
||||||
|
|
||||||
var sendBtn *rod.Element
|
var sendBtn *rod.Element
|
||||||
var err error
|
|
||||||
|
|
||||||
// 先尝试通过选择器查找
|
|
||||||
for _, selector := range sendSelectors {
|
|
||||||
sendBtn, err = c.WaitForElementClickable(selector, 5)
|
|
||||||
if err == nil && sendBtn != nil {
|
|
||||||
c.LogInfo(fmt.Sprintf("找到发送按钮: %s", selector))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没找到,尝试遍历所有button元素
|
// 如果没找到,尝试遍历所有button元素
|
||||||
if sendBtn == nil {
|
|
||||||
c.LogInfo("通过选择器未找到发送按钮,尝试遍历所有button元素...")
|
|
||||||
allButtons, _ := c.Page.Elements("button")
|
allButtons, _ := c.Page.Elements("button")
|
||||||
for _, btn := range allButtons {
|
for _, btn := range allButtons {
|
||||||
// 检查按钮是否可点击且可见
|
// 检查按钮是否可点击且可见
|
||||||
|
|
@ -242,16 +219,6 @@ func (c *DoubaoCollector) clickSendButton() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 最后的fallback:查找最后一个button
|
|
||||||
if sendBtn == nil {
|
|
||||||
buttons, _ := c.Page.Elements("button")
|
|
||||||
if len(buttons) > 0 {
|
|
||||||
sendBtn = buttons[len(buttons)-1]
|
|
||||||
c.LogInfo("使用最后一个button作为发送按钮")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sendBtn == nil {
|
if sendBtn == nil {
|
||||||
return fmt.Errorf("未找到发送按钮")
|
return fmt.Errorf("未找到发送按钮")
|
||||||
|
|
|
||||||
|
|
@ -125,10 +125,10 @@ func (c *WenxinCollector) AskQuestion(question string) (*CollectResult, error) {
|
||||||
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
|
answerStr, isExposure := HighlightKeywordsInHTML(answer, c.KeyWords)
|
||||||
// 获取分享链接
|
// 获取分享链接
|
||||||
shareLink := ""
|
shareLink := ""
|
||||||
link, _ := c.getShareLink()
|
//link, _ := c.getShareLink()
|
||||||
if link != "" {
|
//if link != "" {
|
||||||
shareLink = link
|
// shareLink = link
|
||||||
}
|
//}
|
||||||
|
|
||||||
return &CollectResult{
|
return &CollectResult{
|
||||||
Answer: answerStr,
|
Answer: answerStr,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue