feat: Go backend, enhanced search, new widgets, Docker deploy
Major changes: - Add Go backend (backend/) with microservices architecture - Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler, proxy-manager, media-search, fastClassifier, language detection - New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard, UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions - Improved discover-svc with discover-db integration - Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md) - Library-svc: project_id schema migration - Remove deprecated finance-svc and travel-svc - Localization improvements across services Made-with: Cursor
This commit is contained in:
587
backend/internal/computer/browser/browser.go
Normal file
587
backend/internal/computer/browser/browser.go
Normal file
@@ -0,0 +1,587 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PlaywrightBrowser struct {
|
||||
cmd *exec.Cmd
|
||||
serverURL string
|
||||
client *http.Client
|
||||
sessions map[string]*BrowserSession
|
||||
mu sync.RWMutex
|
||||
config Config
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
PlaywrightServerURL string
|
||||
DefaultTimeout time.Duration
|
||||
Headless bool
|
||||
UserAgent string
|
||||
ProxyURL string
|
||||
ScreenshotsDir string
|
||||
RecordingsDir string
|
||||
}
|
||||
|
||||
type BrowserSession struct {
|
||||
ID string
|
||||
ContextID string
|
||||
PageID string
|
||||
CreatedAt time.Time
|
||||
LastAction time.Time
|
||||
Screenshots []string
|
||||
Recordings []string
|
||||
Closed bool
|
||||
}
|
||||
|
||||
type ActionRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Action string `json:"action"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
type ActionResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PageTitle string `json:"pageTitle,omitempty"`
|
||||
PageURL string `json:"pageUrl,omitempty"`
|
||||
}
|
||||
|
||||
func NewPlaywrightBrowser(cfg Config) *PlaywrightBrowser {
|
||||
if cfg.DefaultTimeout == 0 {
|
||||
cfg.DefaultTimeout = 30 * time.Second
|
||||
}
|
||||
if cfg.PlaywrightServerURL == "" {
|
||||
cfg.PlaywrightServerURL = "http://localhost:3050"
|
||||
}
|
||||
if cfg.ScreenshotsDir == "" {
|
||||
cfg.ScreenshotsDir = "/tmp/gooseek-screenshots"
|
||||
}
|
||||
if cfg.RecordingsDir == "" {
|
||||
cfg.RecordingsDir = "/tmp/gooseek-recordings"
|
||||
}
|
||||
|
||||
os.MkdirAll(cfg.ScreenshotsDir, 0755)
|
||||
os.MkdirAll(cfg.RecordingsDir, 0755)
|
||||
|
||||
return &PlaywrightBrowser{
|
||||
serverURL: cfg.PlaywrightServerURL,
|
||||
client: &http.Client{
|
||||
Timeout: cfg.DefaultTimeout,
|
||||
},
|
||||
sessions: make(map[string]*BrowserSession),
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) NewSession(ctx context.Context, opts SessionOptions) (*BrowserSession, error) {
|
||||
sessionID := uuid.New().String()
|
||||
|
||||
params := map[string]interface{}{
|
||||
"headless": b.config.Headless,
|
||||
"sessionId": sessionID,
|
||||
}
|
||||
|
||||
if opts.Viewport != nil {
|
||||
params["viewport"] = opts.Viewport
|
||||
}
|
||||
if opts.UserAgent != "" {
|
||||
params["userAgent"] = opts.UserAgent
|
||||
} else if b.config.UserAgent != "" {
|
||||
params["userAgent"] = b.config.UserAgent
|
||||
}
|
||||
if opts.ProxyURL != "" {
|
||||
params["proxy"] = opts.ProxyURL
|
||||
} else if b.config.ProxyURL != "" {
|
||||
params["proxy"] = b.config.ProxyURL
|
||||
}
|
||||
if opts.RecordVideo {
|
||||
params["recordVideo"] = map[string]interface{}{
|
||||
"dir": b.config.RecordingsDir,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := b.sendCommand(ctx, "browser.newContext", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create browser context: %w", err)
|
||||
}
|
||||
|
||||
contextID, _ := resp["contextId"].(string)
|
||||
pageID, _ := resp["pageId"].(string)
|
||||
|
||||
session := &BrowserSession{
|
||||
ID: sessionID,
|
||||
ContextID: contextID,
|
||||
PageID: pageID,
|
||||
CreatedAt: time.Now(),
|
||||
LastAction: time.Now(),
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.sessions[sessionID] = session
|
||||
b.mu.Unlock()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) CloseSession(ctx context.Context, sessionID string) error {
|
||||
b.mu.Lock()
|
||||
session, ok := b.sessions[sessionID]
|
||||
if !ok {
|
||||
b.mu.Unlock()
|
||||
return errors.New("session not found")
|
||||
}
|
||||
session.Closed = true
|
||||
delete(b.sessions, sessionID)
|
||||
b.mu.Unlock()
|
||||
|
||||
_, err := b.sendCommand(ctx, "browser.closeContext", map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) Navigate(ctx context.Context, sessionID, url string, opts NavigateOptions) (*ActionResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"url": url,
|
||||
}
|
||||
if opts.Timeout > 0 {
|
||||
params["timeout"] = opts.Timeout
|
||||
}
|
||||
if opts.WaitUntil != "" {
|
||||
params["waitUntil"] = opts.WaitUntil
|
||||
}
|
||||
|
||||
resp, err := b.sendCommand(ctx, "page.goto", params)
|
||||
if err != nil {
|
||||
return &ActionResponse{Success: false, Error: err.Error()}, err
|
||||
}
|
||||
|
||||
result := &ActionResponse{
|
||||
Success: true,
|
||||
PageURL: getString(resp, "url"),
|
||||
PageTitle: getString(resp, "title"),
|
||||
}
|
||||
|
||||
if opts.Screenshot {
|
||||
screenshot, _ := b.Screenshot(ctx, sessionID, ScreenshotOptions{FullPage: false})
|
||||
if screenshot != nil {
|
||||
result.Screenshot = screenshot.Data
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) Click(ctx context.Context, sessionID, selector string, opts ClickOptions) (*ActionResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"selector": selector,
|
||||
}
|
||||
if opts.Button != "" {
|
||||
params["button"] = opts.Button
|
||||
}
|
||||
if opts.ClickCount > 0 {
|
||||
params["clickCount"] = opts.ClickCount
|
||||
}
|
||||
if opts.Timeout > 0 {
|
||||
params["timeout"] = opts.Timeout
|
||||
}
|
||||
if opts.Force {
|
||||
params["force"] = true
|
||||
}
|
||||
|
||||
_, err := b.sendCommand(ctx, "page.click", params)
|
||||
if err != nil {
|
||||
return &ActionResponse{Success: false, Error: err.Error()}, err
|
||||
}
|
||||
|
||||
result := &ActionResponse{Success: true}
|
||||
|
||||
if opts.WaitAfter > 0 {
|
||||
time.Sleep(time.Duration(opts.WaitAfter) * time.Millisecond)
|
||||
}
|
||||
|
||||
if opts.Screenshot {
|
||||
screenshot, _ := b.Screenshot(ctx, sessionID, ScreenshotOptions{FullPage: false})
|
||||
if screenshot != nil {
|
||||
result.Screenshot = screenshot.Data
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) Type(ctx context.Context, sessionID, selector, text string, opts TypeOptions) (*ActionResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"selector": selector,
|
||||
"text": text,
|
||||
}
|
||||
if opts.Delay > 0 {
|
||||
params["delay"] = opts.Delay
|
||||
}
|
||||
if opts.Timeout > 0 {
|
||||
params["timeout"] = opts.Timeout
|
||||
}
|
||||
if opts.Clear {
|
||||
b.sendCommand(ctx, "page.fill", map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"selector": selector,
|
||||
"value": "",
|
||||
})
|
||||
}
|
||||
|
||||
_, err := b.sendCommand(ctx, "page.type", params)
|
||||
if err != nil {
|
||||
return &ActionResponse{Success: false, Error: err.Error()}, err
|
||||
}
|
||||
|
||||
return &ActionResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) Fill(ctx context.Context, sessionID, selector, value string) (*ActionResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"selector": selector,
|
||||
"value": value,
|
||||
}
|
||||
|
||||
_, err := b.sendCommand(ctx, "page.fill", params)
|
||||
if err != nil {
|
||||
return &ActionResponse{Success: false, Error: err.Error()}, err
|
||||
}
|
||||
|
||||
return &ActionResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) Screenshot(ctx context.Context, sessionID string, opts ScreenshotOptions) (*ScreenshotResult, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"fullPage": opts.FullPage,
|
||||
}
|
||||
if opts.Selector != "" {
|
||||
params["selector"] = opts.Selector
|
||||
}
|
||||
if opts.Quality > 0 {
|
||||
params["quality"] = opts.Quality
|
||||
}
|
||||
params["type"] = "png"
|
||||
if opts.Format != "" {
|
||||
params["type"] = opts.Format
|
||||
}
|
||||
|
||||
resp, err := b.sendCommand(ctx, "page.screenshot", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, _ := resp["data"].(string)
|
||||
|
||||
filename := fmt.Sprintf("%s/%s-%d.png", b.config.ScreenshotsDir, sessionID, time.Now().UnixNano())
|
||||
if decoded, err := base64.StdEncoding.DecodeString(data); err == nil {
|
||||
os.WriteFile(filename, decoded, 0644)
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
if session, ok := b.sessions[sessionID]; ok {
|
||||
session.Screenshots = append(session.Screenshots, filename)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
return &ScreenshotResult{
|
||||
Data: data,
|
||||
Path: filename,
|
||||
MimeType: "image/png",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) ExtractText(ctx context.Context, sessionID, selector string) (string, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"selector": selector,
|
||||
}
|
||||
|
||||
resp, err := b.sendCommand(ctx, "page.textContent", params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return getString(resp, "text"), nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) ExtractHTML(ctx context.Context, sessionID, selector string) (string, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"selector": selector,
|
||||
}
|
||||
|
||||
resp, err := b.sendCommand(ctx, "page.innerHTML", params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return getString(resp, "html"), nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) WaitForSelector(ctx context.Context, sessionID, selector string, opts WaitOptions) error {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"selector": selector,
|
||||
}
|
||||
if opts.Timeout > 0 {
|
||||
params["timeout"] = opts.Timeout
|
||||
}
|
||||
if opts.State != "" {
|
||||
params["state"] = opts.State
|
||||
}
|
||||
|
||||
_, err := b.sendCommand(ctx, "page.waitForSelector", params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) WaitForNavigation(ctx context.Context, sessionID string, opts WaitOptions) error {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
}
|
||||
if opts.Timeout > 0 {
|
||||
params["timeout"] = opts.Timeout
|
||||
}
|
||||
if opts.WaitUntil != "" {
|
||||
params["waitUntil"] = opts.WaitUntil
|
||||
}
|
||||
|
||||
_, err := b.sendCommand(ctx, "page.waitForNavigation", params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) Scroll(ctx context.Context, sessionID string, opts ScrollOptions) (*ActionResponse, error) {
|
||||
script := fmt.Sprintf("window.scrollBy(%d, %d)", opts.X, opts.Y)
|
||||
if opts.Selector != "" {
|
||||
script = fmt.Sprintf(`document.querySelector('%s').scrollBy(%d, %d)`, opts.Selector, opts.X, opts.Y)
|
||||
}
|
||||
if opts.ToBottom {
|
||||
script = "window.scrollTo(0, document.body.scrollHeight)"
|
||||
}
|
||||
if opts.ToTop {
|
||||
script = "window.scrollTo(0, 0)"
|
||||
}
|
||||
|
||||
_, err := b.Evaluate(ctx, sessionID, script)
|
||||
if err != nil {
|
||||
return &ActionResponse{Success: false, Error: err.Error()}, err
|
||||
}
|
||||
|
||||
if opts.WaitAfter > 0 {
|
||||
time.Sleep(time.Duration(opts.WaitAfter) * time.Millisecond)
|
||||
}
|
||||
|
||||
return &ActionResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) Evaluate(ctx context.Context, sessionID, script string) (interface{}, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"expression": script,
|
||||
}
|
||||
|
||||
resp, err := b.sendCommand(ctx, "page.evaluate", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp["result"], nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) Select(ctx context.Context, sessionID, selector string, values []string) (*ActionResponse, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"selector": selector,
|
||||
"values": values,
|
||||
}
|
||||
|
||||
_, err := b.sendCommand(ctx, "page.selectOption", params)
|
||||
if err != nil {
|
||||
return &ActionResponse{Success: false, Error: err.Error()}, err
|
||||
}
|
||||
|
||||
return &ActionResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) GetPageInfo(ctx context.Context, sessionID string) (*PageInfo, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
}
|
||||
|
||||
resp, err := b.sendCommand(ctx, "page.info", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PageInfo{
|
||||
URL: getString(resp, "url"),
|
||||
Title: getString(resp, "title"),
|
||||
Content: getString(resp, "content"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) PDF(ctx context.Context, sessionID string, opts PDFOptions) ([]byte, error) {
|
||||
params := map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
}
|
||||
if opts.Format != "" {
|
||||
params["format"] = opts.Format
|
||||
}
|
||||
if opts.Landscape {
|
||||
params["landscape"] = true
|
||||
}
|
||||
if opts.PrintBackground {
|
||||
params["printBackground"] = true
|
||||
}
|
||||
|
||||
resp, err := b.sendCommand(ctx, "page.pdf", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, _ := resp["data"].(string)
|
||||
return base64.StdEncoding.DecodeString(data)
|
||||
}
|
||||
|
||||
func (b *PlaywrightBrowser) sendCommand(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", b.serverURL+"/api/browser", strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if errMsg, ok := result["error"].(string); ok && errMsg != "" {
|
||||
return result, errors.New(errMsg)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SessionOptions struct {
|
||||
Headless bool
|
||||
Viewport *Viewport
|
||||
UserAgent string
|
||||
ProxyURL string
|
||||
RecordVideo bool
|
||||
BlockAds bool
|
||||
}
|
||||
|
||||
type Viewport struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type NavigateOptions struct {
|
||||
Timeout int
|
||||
WaitUntil string
|
||||
Screenshot bool
|
||||
}
|
||||
|
||||
type ClickOptions struct {
|
||||
Button string
|
||||
ClickCount int
|
||||
Timeout int
|
||||
Force bool
|
||||
WaitAfter int
|
||||
Screenshot bool
|
||||
}
|
||||
|
||||
type TypeOptions struct {
|
||||
Delay int
|
||||
Timeout int
|
||||
Clear bool
|
||||
}
|
||||
|
||||
type ScreenshotOptions struct {
|
||||
FullPage bool
|
||||
Selector string
|
||||
Format string
|
||||
Quality int
|
||||
}
|
||||
|
||||
type ScreenshotResult struct {
|
||||
Data string
|
||||
Path string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type WaitOptions struct {
|
||||
Timeout int
|
||||
State string
|
||||
WaitUntil string
|
||||
}
|
||||
|
||||
type ScrollOptions struct {
|
||||
X int
|
||||
Y int
|
||||
Selector string
|
||||
ToBottom bool
|
||||
ToTop bool
|
||||
WaitAfter int
|
||||
}
|
||||
|
||||
type PageInfo struct {
|
||||
URL string
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
type PDFOptions struct {
|
||||
Format string
|
||||
Landscape bool
|
||||
PrintBackground bool
|
||||
}
|
||||
555
backend/internal/computer/browser/server.go
Normal file
555
backend/internal/computer/browser/server.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
)
|
||||
|
||||
type BrowserServer struct {
|
||||
browser *PlaywrightBrowser
|
||||
sessions map[string]*ManagedSession
|
||||
mu sync.RWMutex
|
||||
config ServerConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int
|
||||
MaxSessions int
|
||||
SessionTimeout time.Duration
|
||||
CleanupInterval time.Duration
|
||||
}
|
||||
|
||||
type ManagedSession struct {
|
||||
*BrowserSession
|
||||
LastActive time.Time
|
||||
Actions []ActionLog
|
||||
}
|
||||
|
||||
type ActionLog struct {
|
||||
Action string `json:"action"`
|
||||
Params string `json:"params"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration int64 `json:"durationMs"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type BrowserRequest struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
func NewBrowserServer(cfg ServerConfig) *BrowserServer {
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 3050
|
||||
}
|
||||
if cfg.MaxSessions == 0 {
|
||||
cfg.MaxSessions = 20
|
||||
}
|
||||
if cfg.SessionTimeout == 0 {
|
||||
cfg.SessionTimeout = 30 * time.Minute
|
||||
}
|
||||
if cfg.CleanupInterval == 0 {
|
||||
cfg.CleanupInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
return &BrowserServer{
|
||||
browser: NewPlaywrightBrowser(Config{
|
||||
DefaultTimeout: 30 * time.Second,
|
||||
Headless: true,
|
||||
}),
|
||||
sessions: make(map[string]*ManagedSession),
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BrowserServer) Start(ctx context.Context) error {
|
||||
go s.cleanupLoop(ctx)
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
BodyLimit: 50 * 1024 * 1024,
|
||||
ReadTimeout: 2 * time.Minute,
|
||||
WriteTimeout: 2 * time.Minute,
|
||||
})
|
||||
|
||||
app.Use(logger.New())
|
||||
app.Use(cors.New())
|
||||
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "sessions": len(s.sessions)})
|
||||
})
|
||||
|
||||
app.Post("/api/browser", s.handleBrowserCommand)
|
||||
|
||||
app.Post("/api/session/new", s.handleNewSession)
|
||||
app.Delete("/api/session/:id", s.handleCloseSession)
|
||||
app.Get("/api/session/:id", s.handleGetSession)
|
||||
app.Get("/api/sessions", s.handleListSessions)
|
||||
|
||||
app.Post("/api/action", s.handleAction)
|
||||
|
||||
log.Printf("[BrowserServer] Starting on port %d", s.config.Port)
|
||||
return app.Listen(fmt.Sprintf(":%d", s.config.Port))
|
||||
}
|
||||
|
||||
func (s *BrowserServer) handleBrowserCommand(c *fiber.Ctx) error {
|
||||
var req BrowserRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sessionID, _ := req.Params["sessionId"].(string)
|
||||
|
||||
s.mu.Lock()
|
||||
if session, ok := s.sessions[sessionID]; ok {
|
||||
session.LastActive = time.Now()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
result, err := s.executeMethod(ctx, req.Method, req.Params)
|
||||
|
||||
s.mu.Lock()
|
||||
if session, ok := s.sessions[sessionID]; ok {
|
||||
paramsJSON, _ := json.Marshal(req.Params)
|
||||
session.Actions = append(session.Actions, ActionLog{
|
||||
Action: req.Method,
|
||||
Params: string(paramsJSON),
|
||||
Success: err == nil,
|
||||
Error: errToString(err),
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(fiber.Map{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func (s *BrowserServer) executeMethod(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) {
|
||||
sessionID, _ := params["sessionId"].(string)
|
||||
|
||||
switch method {
|
||||
case "browser.newContext":
|
||||
opts := SessionOptions{
|
||||
Headless: getBool(params, "headless"),
|
||||
}
|
||||
if viewport, ok := params["viewport"].(map[string]interface{}); ok {
|
||||
opts.Viewport = &Viewport{
|
||||
Width: getInt(viewport, "width"),
|
||||
Height: getInt(viewport, "height"),
|
||||
}
|
||||
}
|
||||
if ua, ok := params["userAgent"].(string); ok {
|
||||
opts.UserAgent = ua
|
||||
}
|
||||
if proxy, ok := params["proxy"].(string); ok {
|
||||
opts.ProxyURL = proxy
|
||||
}
|
||||
if rv, ok := params["recordVideo"].(map[string]interface{}); ok {
|
||||
_ = rv
|
||||
opts.RecordVideo = true
|
||||
}
|
||||
|
||||
session, err := s.browser.NewSession(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[session.ID] = &ManagedSession{
|
||||
BrowserSession: session,
|
||||
LastActive: time.Now(),
|
||||
Actions: make([]ActionLog, 0),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"sessionId": session.ID,
|
||||
"contextId": session.ContextID,
|
||||
"pageId": session.PageID,
|
||||
}, nil
|
||||
|
||||
case "browser.closeContext":
|
||||
err := s.browser.CloseSession(ctx, sessionID)
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, sessionID)
|
||||
s.mu.Unlock()
|
||||
return map[string]interface{}{"success": err == nil}, err
|
||||
|
||||
case "page.goto":
|
||||
url, _ := params["url"].(string)
|
||||
opts := NavigateOptions{
|
||||
Timeout: getInt(params, "timeout"),
|
||||
WaitUntil: getString(params, "waitUntil"),
|
||||
}
|
||||
result, err := s.browser.Navigate(ctx, sessionID, url, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"success": result.Success,
|
||||
"url": result.PageURL,
|
||||
"title": result.PageTitle,
|
||||
}, nil
|
||||
|
||||
case "page.click":
|
||||
selector, _ := params["selector"].(string)
|
||||
opts := ClickOptions{
|
||||
Button: getString(params, "button"),
|
||||
ClickCount: getInt(params, "clickCount"),
|
||||
Timeout: getInt(params, "timeout"),
|
||||
Force: getBool(params, "force"),
|
||||
}
|
||||
result, err := s.browser.Click(ctx, sessionID, selector, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"success": result.Success,
|
||||
"screenshot": result.Screenshot,
|
||||
}, nil
|
||||
|
||||
case "page.type":
|
||||
selector, _ := params["selector"].(string)
|
||||
text, _ := params["text"].(string)
|
||||
opts := TypeOptions{
|
||||
Delay: getInt(params, "delay"),
|
||||
Timeout: getInt(params, "timeout"),
|
||||
}
|
||||
_, err := s.browser.Type(ctx, sessionID, selector, text, opts)
|
||||
return map[string]interface{}{"success": err == nil}, err
|
||||
|
||||
case "page.fill":
|
||||
selector, _ := params["selector"].(string)
|
||||
value, _ := params["value"].(string)
|
||||
_, err := s.browser.Fill(ctx, sessionID, selector, value)
|
||||
return map[string]interface{}{"success": err == nil}, err
|
||||
|
||||
case "page.screenshot":
|
||||
opts := ScreenshotOptions{
|
||||
FullPage: getBool(params, "fullPage"),
|
||||
Selector: getString(params, "selector"),
|
||||
Format: getString(params, "type"),
|
||||
Quality: getInt(params, "quality"),
|
||||
}
|
||||
result, err := s.browser.Screenshot(ctx, sessionID, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"data": result.Data,
|
||||
"path": result.Path,
|
||||
}, nil
|
||||
|
||||
case "page.textContent":
|
||||
selector, _ := params["selector"].(string)
|
||||
text, err := s.browser.ExtractText(ctx, sessionID, selector)
|
||||
return map[string]interface{}{"text": text}, err
|
||||
|
||||
case "page.innerHTML":
|
||||
selector, _ := params["selector"].(string)
|
||||
html, err := s.browser.ExtractHTML(ctx, sessionID, selector)
|
||||
return map[string]interface{}{"html": html}, err
|
||||
|
||||
case "page.waitForSelector":
|
||||
selector, _ := params["selector"].(string)
|
||||
opts := WaitOptions{
|
||||
Timeout: getInt(params, "timeout"),
|
||||
State: getString(params, "state"),
|
||||
}
|
||||
err := s.browser.WaitForSelector(ctx, sessionID, selector, opts)
|
||||
return map[string]interface{}{"success": err == nil}, err
|
||||
|
||||
case "page.waitForNavigation":
|
||||
opts := WaitOptions{
|
||||
Timeout: getInt(params, "timeout"),
|
||||
WaitUntil: getString(params, "waitUntil"),
|
||||
}
|
||||
err := s.browser.WaitForNavigation(ctx, sessionID, opts)
|
||||
return map[string]interface{}{"success": err == nil}, err
|
||||
|
||||
case "page.evaluate":
|
||||
expression, _ := params["expression"].(string)
|
||||
result, err := s.browser.Evaluate(ctx, sessionID, expression)
|
||||
return map[string]interface{}{"result": result}, err
|
||||
|
||||
case "page.selectOption":
|
||||
selector, _ := params["selector"].(string)
|
||||
values := getStringArray(params, "values")
|
||||
_, err := s.browser.Select(ctx, sessionID, selector, values)
|
||||
return map[string]interface{}{"success": err == nil}, err
|
||||
|
||||
case "page.info":
|
||||
info, err := s.browser.GetPageInfo(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"url": info.URL,
|
||||
"title": info.Title,
|
||||
"content": info.Content,
|
||||
}, nil
|
||||
|
||||
case "page.pdf":
|
||||
opts := PDFOptions{
|
||||
Format: getString(params, "format"),
|
||||
Landscape: getBool(params, "landscape"),
|
||||
PrintBackground: getBool(params, "printBackground"),
|
||||
}
|
||||
data, err := s.browser.PDF(ctx, sessionID, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"data": data,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown method: %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BrowserServer) handleNewSession(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Headless bool `json:"headless"`
|
||||
Viewport *Viewport `json:"viewport,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
ProxyURL string `json:"proxyUrl,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
req.Headless = true
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
if len(s.sessions) >= s.config.MaxSessions {
|
||||
s.mu.RUnlock()
|
||||
return c.Status(http.StatusTooManyRequests).JSON(fiber.Map{
|
||||
"error": "Maximum sessions limit reached",
|
||||
})
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := s.browser.NewSession(ctx, SessionOptions{
|
||||
Headless: req.Headless,
|
||||
Viewport: req.Viewport,
|
||||
UserAgent: req.UserAgent,
|
||||
ProxyURL: req.ProxyURL,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[session.ID] = &ManagedSession{
|
||||
BrowserSession: session,
|
||||
LastActive: time.Now(),
|
||||
Actions: make([]ActionLog, 0),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"sessionId": session.ID,
|
||||
"contextId": session.ContextID,
|
||||
"pageId": session.PageID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BrowserServer) handleCloseSession(c *fiber.Ctx) error {
|
||||
sessionID := c.Params("id")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := s.browser.CloseSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, sessionID)
|
||||
s.mu.Unlock()
|
||||
|
||||
return c.JSON(fiber.Map{"success": true})
|
||||
}
|
||||
|
||||
func (s *BrowserServer) handleGetSession(c *fiber.Ctx) error {
|
||||
sessionID := c.Params("id")
|
||||
|
||||
s.mu.RLock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Session not found"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"sessionId": session.ID,
|
||||
"createdAt": session.CreatedAt,
|
||||
"lastActive": session.LastActive,
|
||||
"screenshots": session.Screenshots,
|
||||
"actions": len(session.Actions),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BrowserServer) handleListSessions(c *fiber.Ctx) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
sessions := make([]map[string]interface{}, 0, len(s.sessions))
|
||||
for _, session := range s.sessions {
|
||||
sessions = append(sessions, map[string]interface{}{
|
||||
"sessionId": session.ID,
|
||||
"createdAt": session.CreatedAt,
|
||||
"lastActive": session.LastActive,
|
||||
"actions": len(session.Actions),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"sessions": sessions, "count": len(sessions)})
|
||||
}
|
||||
|
||||
func (s *BrowserServer) handleAction(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Action string `json:"action"`
|
||||
Selector string `json:"selector,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Screenshot bool `json:"screenshot"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.mu.Lock()
|
||||
if session, ok := s.sessions[req.SessionID]; ok {
|
||||
session.LastActive = time.Now()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
var result *ActionResponse
|
||||
var err error
|
||||
|
||||
switch req.Action {
|
||||
case "navigate":
|
||||
result, err = s.browser.Navigate(ctx, req.SessionID, req.URL, NavigateOptions{Screenshot: req.Screenshot})
|
||||
case "click":
|
||||
result, err = s.browser.Click(ctx, req.SessionID, req.Selector, ClickOptions{Screenshot: req.Screenshot})
|
||||
case "type":
|
||||
result, err = s.browser.Type(ctx, req.SessionID, req.Selector, req.Value, TypeOptions{})
|
||||
case "fill":
|
||||
result, err = s.browser.Fill(ctx, req.SessionID, req.Selector, req.Value)
|
||||
case "screenshot":
|
||||
var screenshot *ScreenshotResult
|
||||
screenshot, err = s.browser.Screenshot(ctx, req.SessionID, ScreenshotOptions{})
|
||||
if err == nil {
|
||||
result = &ActionResponse{Success: true, Screenshot: screenshot.Data}
|
||||
}
|
||||
case "extract":
|
||||
var text string
|
||||
text, err = s.browser.ExtractText(ctx, req.SessionID, req.Selector)
|
||||
result = &ActionResponse{Success: err == nil, Data: text}
|
||||
default:
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Unknown action: " + req.Action})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error(), "success": false})
|
||||
}
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
func (s *BrowserServer) cleanupLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.config.CleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.cleanupExpiredSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BrowserServer) cleanupExpiredSessions() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for sessionID, session := range s.sessions {
|
||||
if now.Sub(session.LastActive) > s.config.SessionTimeout {
|
||||
log.Printf("[BrowserServer] Cleaning up expired session: %s", sessionID)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
s.browser.CloseSession(ctx, sessionID)
|
||||
cancel()
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errToString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func getBool(m map[string]interface{}, key string) bool {
|
||||
if v, ok := m[key].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getInt(m map[string]interface{}, key string) int {
|
||||
if v, ok := m[key].(float64); ok {
|
||||
return int(v)
|
||||
}
|
||||
if v, ok := m[key].(int); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getStringArray(m map[string]interface{}, key string) []string {
|
||||
if v, ok := m[key].([]interface{}); ok {
|
||||
result := make([]string, len(v))
|
||||
for i, item := range v {
|
||||
result[i], _ = item.(string)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user