package browser import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "time" ) // NavigateOptions opciones para la navegación. type NavigateOptions struct { // WaitUntil define cuándo se considera completada la navegación // "load" = evento load, "domcontentloaded" = DOM listo, "networkidle" = red inactiva WaitUntil string // Timeout para la navegación Timeout time.Duration // Referer personalizado Referer string } // DefaultNavigateOptions retorna opciones por defecto. func DefaultNavigateOptions() *NavigateOptions { return &NavigateOptions{ WaitUntil: "load", Timeout: 30 * time.Second, } } // Navigate navega a una URL. func (b *Browser) Navigate(ctx context.Context, url string, opts *NavigateOptions) error { if opts == nil { opts = DefaultNavigateOptions() } navCtx, cancel := context.WithTimeout(ctx, opts.Timeout) defer cancel() // Preparar parámetros params := map[string]interface{}{ "url": url, } if opts.Referer != "" { params["referrer"] = opts.Referer } // Canal para eventos de navegación loadedCh := make(chan struct{}) var loadErr error // Registrar eventos según WaitUntil switch opts.WaitUntil { case "domcontentloaded": b.cdpClient.On("Page.domContentEventFired", func(params json.RawMessage) { close(loadedCh) }) case "networkidle": // Implementación simple: esperar a que no haya requests por 500ms idleTimer := time.NewTimer(500 * time.Millisecond) activeRequests := 0 b.cdpClient.On("Network.requestWillBeSent", func(params json.RawMessage) { activeRequests++ idleTimer.Reset(500 * time.Millisecond) }) b.cdpClient.On("Network.loadingFinished", func(params json.RawMessage) { activeRequests-- if activeRequests <= 0 { idleTimer.Reset(500 * time.Millisecond) } }) b.cdpClient.On("Network.loadingFailed", func(params json.RawMessage) { activeRequests-- if activeRequests <= 0 { idleTimer.Reset(500 * time.Millisecond) } }) go func() { <-idleTimer.C close(loadedCh) }() default: // "load" b.cdpClient.On("Page.loadEventFired", func(params json.RawMessage) { close(loadedCh) }) } // Navegar if err := b.cdpClient.Execute(navCtx, "Page.navigate", params, nil); err != nil { return fmt.Errorf("failed to navigate: %w", err) } // Esperar a que se complete la navegación var err error select { case <-loadedCh: err = loadErr case <-navCtx.Done(): err = fmt.Errorf("navigation timeout: %w", navCtx.Err()) } // Registrar acción if b.recorder != nil { b.recorder.Record("Navigate", map[string]interface{}{ "url": url, "waitUntil": opts.WaitUntil, }, nil, err) } return err } // Click hace clic en un elemento usando un selector CSS. func (b *Browser) Click(ctx context.Context, selector string) error { // Obtener el NodeID del elemento nodeID, err := b.querySelector(ctx, selector) if err != nil { return err } // Obtener las coordenadas del elemento box, err := b.getElementBox(ctx, nodeID) if err != nil { return err } // Calcular centro del elemento x := box.X + box.Width/2 y := box.Y + box.Height/2 // Simular click (mousePressed + mouseReleased) if err := b.cdpClient.Execute(ctx, "Input.dispatchMouseEvent", map[string]interface{}{ "type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1, }, nil); err != nil { return fmt.Errorf("failed to press mouse: %w", err) } // Pequeño delay entre pressed y released (más natural) time.Sleep(50 * time.Millisecond) err = b.cdpClient.Execute(ctx, "Input.dispatchMouseEvent", map[string]interface{}{ "type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1, }, nil) if err != nil { if b.recorder != nil { b.recorder.Record("Click", map[string]interface{}{"selector": selector}, nil, err) } return fmt.Errorf("failed to release mouse: %w", err) } // Registrar acción if b.recorder != nil { b.recorder.Record("Click", map[string]interface{}{"selector": selector}, nil, nil) } return nil } // Type escribe texto en un elemento. func (b *Browser) Type(ctx context.Context, selector string, text string, opts *TypeOptions) error { if opts == nil { opts = DefaultTypeOptions() } // Focus en el elemento primero if err := b.Focus(ctx, selector); err != nil { if b.recorder != nil { b.recorder.Record("Type", map[string]interface{}{ "selector": selector, "text": text, }, nil, err) } return err } // Escribir cada carácter con delay for _, char := range text { if err := b.typeChar(ctx, string(char)); err != nil { if b.recorder != nil { b.recorder.Record("Type", map[string]interface{}{ "selector": selector, "text": text, }, nil, err) } return err } if opts.Delay > 0 { time.Sleep(opts.Delay) } } // Registrar acción exitosa if b.recorder != nil { b.recorder.Record("Type", map[string]interface{}{ "selector": selector, "text": text, "delay": opts.Delay.String(), }, nil, nil) } return nil } // TypeOptions opciones para escribir texto. type TypeOptions struct { // Delay entre caracteres (más natural) Delay time.Duration } // DefaultTypeOptions retorna opciones por defecto. func DefaultTypeOptions() *TypeOptions { return &TypeOptions{ Delay: 50 * time.Millisecond, } } // typeChar escribe un solo carácter. func (b *Browser) typeChar(ctx context.Context, char string) error { params := map[string]interface{}{ "type": "char", "text": char, } return b.cdpClient.Execute(ctx, "Input.dispatchKeyEvent", params, nil) } // Focus hace foco en un elemento. func (b *Browser) Focus(ctx context.Context, selector string) error { nodeID, err := b.querySelector(ctx, selector) if err != nil { return err } params := map[string]interface{}{ "nodeId": nodeID, } return b.cdpClient.Execute(ctx, "DOM.focus", params, nil) } // Screenshot toma una captura de pantalla. func (b *Browser) Screenshot(ctx context.Context, fullPage bool) ([]byte, error) { params := map[string]interface{}{ "format": "png", "captureBeyondViewport": fullPage, } var result struct { Data string `json:"data"` } if err := b.cdpClient.Execute(ctx, "Page.captureScreenshot", params, &result); err != nil { return nil, fmt.Errorf("failed to capture screenshot: %w", err) } data, err := base64.StdEncoding.DecodeString(result.Data) if err != nil { return nil, fmt.Errorf("failed to decode screenshot: %w", err) } return data, nil } // GetHTML obtiene el HTML de la página o de un elemento específico. func (b *Browser) GetHTML(ctx context.Context, selector string) (string, error) { if selector == "" { // Obtener HTML completo var result struct { Result struct { Value string `json:"value"` } `json:"result"` } params := map[string]interface{}{ "expression": "document.documentElement.outerHTML", } if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil { return "", fmt.Errorf("failed to get HTML: %w", err) } return result.Result.Value, nil } // Obtener HTML de un elemento específico nodeID, err := b.querySelector(ctx, selector) if err != nil { return "", err } var result struct { OuterHTML string `json:"outerHTML"` } params := map[string]interface{}{ "nodeId": nodeID, } if err := b.cdpClient.Execute(ctx, "DOM.getOuterHTML", params, &result); err != nil { return "", fmt.Errorf("failed to get HTML: %w", err) } return result.OuterHTML, nil } // GetText obtiene el texto visible de un elemento. func (b *Browser) GetText(ctx context.Context, selector string) (string, error) { script := fmt.Sprintf(`document.querySelector('%s')?.textContent`, selector) var result struct { Result struct { Value string `json:"value"` } `json:"result"` } params := map[string]interface{}{ "expression": script, } if err := b.cdpClient.Execute(ctx, "Runtime.evaluate", params, &result); err != nil { return "", fmt.Errorf("failed to get text: %w", err) } return result.Result.Value, nil } // WaitForSelector espera a que un selector esté disponible. func (b *Browser) WaitForSelector(ctx context.Context, selector string, timeout time.Duration) error { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return fmt.Errorf("timeout waiting for selector: %s", selector) case <-ticker.C: _, err := b.querySelector(ctx, selector) if err == nil { return nil } } } } // querySelector helper para obtener NodeID de un selector. func (b *Browser) querySelector(ctx context.Context, selector string) (int64, error) { // Primero obtener el documento root var docResult struct { Root struct { NodeID int64 `json:"nodeId"` } `json:"root"` } if err := b.cdpClient.Execute(ctx, "DOM.getDocument", nil, &docResult); err != nil { return 0, fmt.Errorf("failed to get document: %w", err) } // Buscar el elemento var queryResult struct { NodeID int64 `json:"nodeId"` } params := map[string]interface{}{ "nodeId": docResult.Root.NodeID, "selector": selector, } if err := b.cdpClient.Execute(ctx, "DOM.querySelector", params, &queryResult); err != nil { return 0, fmt.Errorf("failed to query selector: %w", err) } if queryResult.NodeID == 0 { return 0, fmt.Errorf("element not found: %s", selector) } return queryResult.NodeID, nil } // Box representa las coordenadas de un elemento. type Box struct { X float64 Y float64 Width float64 Height float64 } // getElementBox obtiene las coordenadas de un elemento. func (b *Browser) getElementBox(ctx context.Context, nodeID int64) (*Box, error) { var result struct { Model struct { Content []float64 `json:"content"` } `json:"model"` } params := map[string]interface{}{ "nodeId": nodeID, } if err := b.cdpClient.Execute(ctx, "DOM.getBoxModel", params, &result); err != nil { return nil, fmt.Errorf("failed to get box model: %w", err) } if len(result.Model.Content) < 8 { return nil, errors.New("invalid box model") } // Content es un array [x1, y1, x2, y2, x3, y3, x4, y4] (cuatro esquinas) x := result.Model.Content[0] y := result.Model.Content[1] width := result.Model.Content[2] - x height := result.Model.Content[5] - y return &Box{ X: x, Y: y, Width: width, Height: height, }, nil } // Reload recarga la página actual. func (b *Browser) Reload(ctx context.Context) error { return b.cdpClient.Execute(ctx, "Page.reload", nil, nil) } // GoBack navega hacia atrás en el historial. func (b *Browser) GoBack(ctx context.Context) error { // Obtener historial var history struct { CurrentIndex int `json:"currentIndex"` Entries []struct { ID int `json:"id"` } `json:"entries"` } if err := b.cdpClient.Execute(ctx, "Page.getNavigationHistory", nil, &history); err != nil { return fmt.Errorf("failed to get history: %w", err) } if history.CurrentIndex <= 0 { return errors.New("no history to go back") } // Navegar a la entrada anterior params := map[string]interface{}{ "entryId": history.Entries[history.CurrentIndex-1].ID, } return b.cdpClient.Execute(ctx, "Page.navigateToHistoryEntry", params, nil) } // GoForward navega hacia adelante en el historial. func (b *Browser) GoForward(ctx context.Context) error { var history struct { CurrentIndex int `json:"currentIndex"` Entries []struct { ID int `json:"id"` } `json:"entries"` } if err := b.cdpClient.Execute(ctx, "Page.getNavigationHistory", nil, &history); err != nil { return fmt.Errorf("failed to get history: %w", err) } if history.CurrentIndex >= len(history.Entries)-1 { return errors.New("no history to go forward") } params := map[string]interface{}{ "entryId": history.Entries[history.CurrentIndex+1].ID, } return b.cdpClient.Execute(ctx, "Page.navigateToHistoryEntry", params, nil) }