403 lines
8.1 KiB
Go
403 lines
8.1 KiB
Go
package browser
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"math/rand"
|
|
"os/exec"
|
|
// "strings"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/cdp"
|
|
"github.com/chromedp/cdproto/dom"
|
|
"github.com/chromedp/cdproto/page"
|
|
"github.com/chromedp/chromedp"
|
|
)
|
|
|
|
type ChromeInstance struct {
|
|
WSURL string
|
|
Cmd *exec.Cmd
|
|
}
|
|
|
|
type Browser struct {
|
|
Instance *ChromeInstance
|
|
allocCtx context.Context
|
|
allocCancel context.CancelFunc
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// NewBrowser launches Chrome and creates a shared allocator.
|
|
// Use NewTab() to create separate tabs for concurrent use.
|
|
func NewBrowser(ctx context.Context) (*Browser, error) {
|
|
wsURL, cmd, err := launchChrome()
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
allocCtx, allocCancel := chromedp.NewRemoteAllocator(ctx, wsURL)
|
|
|
|
// Create initial browser context (first tab)
|
|
browserCtx, cancel := chromedp.NewContext(allocCtx)
|
|
|
|
return &Browser{
|
|
Instance: &ChromeInstance{
|
|
WSURL: wsURL,
|
|
Cmd: cmd,
|
|
},
|
|
allocCtx: allocCtx,
|
|
allocCancel: allocCancel,
|
|
ctx: browserCtx,
|
|
cancel: cancel,
|
|
}, nil
|
|
}
|
|
|
|
// NewTab creates a new browser tab that can be used concurrently.
|
|
// Returns a new Browser instance sharing the same Chrome process.
|
|
func (b *Browser) NewTab() (*Browser, error) {
|
|
tabCtx, cancel := chromedp.NewContext(b.allocCtx)
|
|
|
|
return &Browser{
|
|
Instance: b.Instance,
|
|
allocCtx: b.allocCtx,
|
|
allocCancel: nil, // Don't close allocator when closing a tab
|
|
ctx: tabCtx,
|
|
cancel: cancel,
|
|
}, nil
|
|
}
|
|
|
|
func (b *Browser) Close() error {
|
|
if b.cancel != nil {
|
|
b.cancel()
|
|
}
|
|
|
|
if b.allocCancel != nil {
|
|
b.allocCancel()
|
|
}
|
|
|
|
// Only kill Chrome process if this is the main browser (has allocCancel),
|
|
// not a tab created via NewTab()
|
|
if b.allocCancel != nil && b.Instance != nil && b.Instance.Cmd != nil && b.Instance.Cmd.Process != nil {
|
|
return b.Instance.Cmd.Process.Kill()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *Browser) Navigate(url string) error {
|
|
chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36")
|
|
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.Navigate(url),
|
|
)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *Browser) WaitVisible(selector string) error {
|
|
ctx, cancel := context.WithTimeout(b.ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
err := chromedp.Run(ctx,
|
|
chromedp.WaitVisible(selector, chromedp.ByQuery),
|
|
)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *Browser) GetNodes(selector string) ([]*cdp.Node, error) {
|
|
var nodes []*cdp.Node
|
|
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector, chromedp.ByQueryAll),
|
|
chromedp.Nodes(selector, &nodes, chromedp.ByQueryAll),
|
|
)
|
|
|
|
if err != nil {
|
|
return nodes, err
|
|
}
|
|
|
|
return nodes, nil
|
|
}
|
|
|
|
// GetOuterHTML gets the outer HTML of the first element matching the selector
|
|
func (b *Browser) GetOuterHTML(selector string) (string, error) {
|
|
var html string
|
|
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector, chromedp.ByQuery),
|
|
chromedp.OuterHTML(selector, &html, chromedp.ByQuery),
|
|
)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return html, nil
|
|
}
|
|
|
|
func (b *Browser) GetTextFromNode(node *cdp.Node, selector string) (string, error) {
|
|
var text string
|
|
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.Text(selector, &text, chromedp.ByQuery, chromedp.FromNode(node)),
|
|
)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return text, nil
|
|
}
|
|
|
|
func (b *Browser) GetAttributeFromNode(node *cdp.Node, selector string, attr string) (string, error) {
|
|
var value string
|
|
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.AttributeValue(selector, attr, &value, nil, chromedp.ByQuery, chromedp.FromNode(node)),
|
|
)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// value = strings.ReplaceAll(value, "/apply", "")
|
|
|
|
return value, nil
|
|
}
|
|
|
|
func Evaluate[T any](b *Browser, expression string) (T, error) {
|
|
var result T
|
|
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.Evaluate(expression, &result),
|
|
)
|
|
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (b *Browser) GetCurrentLocation() (string, error) {
|
|
var finalURL string
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.Location(&finalURL),
|
|
)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to get current location: %v", err)
|
|
return "", err
|
|
}
|
|
|
|
return finalURL, nil
|
|
}
|
|
|
|
func (b *Browser) GetHTML() (string, error) {
|
|
var htmlContent string
|
|
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.OuterHTML("html", &htmlContent, chromedp.ByQuery),
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to get HTML content: %v", err)
|
|
return "", err
|
|
}
|
|
|
|
return htmlContent, nil
|
|
}
|
|
|
|
func (b *Browser) WaitForNetworkIdle(timeout time.Duration) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
return page.SetLifecycleEventsEnabled(true).Do(ctx)
|
|
}),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ch := make(chan struct{}, 1)
|
|
chromedp.ListenTarget(b.ctx, func(ev interface{}) {
|
|
if e, ok := ev.(*page.EventLifecycleEvent); ok {
|
|
if e.Name == "networkIdle" {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
select {
|
|
case <-ch:
|
|
return nil
|
|
case <-time.After(timeout):
|
|
// Timeout reached, but don't error - page may still be usable
|
|
return nil
|
|
case <-b.ctx.Done():
|
|
return b.ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (b *Browser) NodeToString(node *cdp.Node) (string, error) {
|
|
var outerHTML string
|
|
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
h, err := dom.GetOuterHTML().WithNodeID(node.NodeID).Do(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
outerHTML = h
|
|
return nil
|
|
}),
|
|
)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return outerHTML, err
|
|
}
|
|
|
|
func (b *Browser) Type(selector string, value string) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector),
|
|
chromedp.SendKeys(selector, value),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *Browser) Click(selector string) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector),
|
|
chromedp.Click(selector),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Select chooses an option from a <select> element by value
|
|
func (b *Browser) Select(selector string, value string) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector),
|
|
chromedp.SetValue(selector, value),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetCheckbox sets a checkbox to checked or unchecked state
|
|
func (b *Browser) SetCheckbox(selector string, checked bool) error {
|
|
var isChecked bool
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector),
|
|
chromedp.Evaluate(`document.querySelector('`+selector+`').checked`, &isChecked),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if isChecked != checked {
|
|
err = chromedp.Run(b.ctx,
|
|
chromedp.Click(selector),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetRadio clicks a radio button to select it
|
|
func (b *Browser) SetRadio(selector string) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector),
|
|
chromedp.Click(selector),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UploadFile sets a file input to the specified file path
|
|
func (b *Browser) UploadFile(selector string, filePath string) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector),
|
|
chromedp.SetUploadFiles(selector, []string{filePath}),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetValue sets the value of an input field directly (useful for hidden, date, color, etc.)
|
|
func (b *Browser) SetValue(selector string, value string) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitReady(selector),
|
|
chromedp.SetValue(selector, value),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Clear clears the value of an input field
|
|
func (b *Browser) Clear(selector string) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector),
|
|
chromedp.Clear(selector),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Focus focuses on an element
|
|
func (b *Browser) Focus(selector string) error {
|
|
err := chromedp.Run(b.ctx,
|
|
chromedp.WaitVisible(selector),
|
|
chromedp.Focus(selector),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RandomDelay waits for a random duration between min and max milliseconds
|
|
func RandomDelay(minMs, maxMs int) {
|
|
delay := time.Duration(minMs+rand.Intn(maxMs-minMs)) * time.Millisecond
|
|
time.Sleep(delay)
|
|
}
|