jobs-monorepo/internal/browser/browser.go
2026-01-26 17:33:48 +05:00

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)
}