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