| package bridge |
|
|
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "math" |
| "sync/atomic" |
| "time" |
|
|
| "github.com/chromedp/cdproto/cdp" |
| "github.com/chromedp/cdproto/fetch" |
| "github.com/chromedp/cdproto/network" |
| "github.com/chromedp/cdproto/page" |
| "github.com/chromedp/chromedp" |
| ) |
|
|
| const TargetTypePage = "page" |
|
|
| |
| func NavigatePage(ctx context.Context, url string) error { |
| err := chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| _, _, _, _, err := page.Navigate(url).Do(ctx) |
| return err |
| }), |
| ) |
| if err != nil { |
| return err |
| } |
|
|
| ticker := time.NewTicker(200 * time.Millisecond) |
| defer ticker.Stop() |
|
|
| for { |
| select { |
| case <-ctx.Done(): |
| return ctx.Err() |
| case <-ticker.C: |
| var state string |
| err = chromedp.Run(ctx, |
| chromedp.Evaluate("document.readyState", &state), |
| ) |
| if err == nil && (state == "interactive" || state == "complete") { |
| return nil |
| } |
| } |
| } |
| } |
|
|
| |
| var ErrTooManyRedirects = fmt.Errorf("too many redirects") |
|
|
| |
| |
| |
| |
| func NavigatePageWithRedirectLimit(ctx context.Context, url string, maxRedirects int) error { |
| |
| if maxRedirects < 0 { |
| return NavigatePage(ctx, url) |
| } |
|
|
| |
| |
| if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| return fetch.Enable().Do(ctx) |
| })); err != nil { |
| return fmt.Errorf("fetch enable: %w", err) |
| } |
|
|
| var redirectCount atomic.Int32 |
| var blocked atomic.Bool |
|
|
| chromedp.ListenTarget(ctx, func(ev interface{}) { |
| e, ok := ev.(*fetch.EventRequestPaused) |
| if !ok { |
| return |
| } |
| |
| go func() { |
| reqID := e.RequestID |
| |
| if e.RedirectedRequestID != "" { |
| count := int(redirectCount.Add(1)) |
| if count > maxRedirects { |
| blocked.Store(true) |
| _ = fetch.FailRequest(reqID, network.ErrorReasonBlockedByClient).Do(cdp.WithExecutor(ctx, chromedp.FromContext(ctx).Target)) |
| return |
| } |
| } |
| |
| _ = fetch.ContinueRequest(reqID).Do(cdp.WithExecutor(ctx, chromedp.FromContext(ctx).Target)) |
| }() |
| }) |
|
|
| |
| err := chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| _, _, _, _, err := page.Navigate(url).Do(ctx) |
| return err |
| }), |
| ) |
|
|
| |
| _ = chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| return fetch.Disable().Do(ctx) |
| })) |
|
|
| if blocked.Load() { |
| return fmt.Errorf("%w: got %d, max %d", ErrTooManyRedirects, redirectCount.Load(), maxRedirects) |
| } |
| if err != nil { |
| return err |
| } |
|
|
| |
| ticker := time.NewTicker(200 * time.Millisecond) |
| defer ticker.Stop() |
|
|
| for { |
| select { |
| case <-ctx.Done(): |
| return ctx.Err() |
| case <-ticker.C: |
| var state string |
| err := chromedp.Run(ctx, |
| chromedp.Evaluate("document.readyState", &state), |
| ) |
| if err == nil && (state == "interactive" || state == "complete") { |
| return nil |
| } |
| } |
| } |
| } |
|
|
| var ImageBlockPatterns = []string{ |
| "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.svg", "*.ico", |
| } |
|
|
| var MediaBlockPatterns = append(ImageBlockPatterns, |
| "*.mp4", "*.webm", "*.ogg", "*.mp3", "*.wav", "*.flac", "*.aac", |
| ) |
|
|
| |
| func SetResourceBlocking(ctx context.Context, patterns []string) error { |
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| if len(patterns) == 0 { |
| return network.SetBlockedURLs([]string{}).Do(ctx) |
| } |
| return network.SetBlockedURLs(patterns).Do(ctx) |
| }), |
| ) |
| } |
|
|
| func ClickByCoordinate(ctx context.Context, x, y float64) error { |
| if x < 0 || y < 0 { |
| return fmt.Errorf("x/y coordinates must be >= 0") |
| } |
|
|
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, |
| "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mousePressed", |
| "button": "left", |
| "clickCount": 1, |
| "x": x, |
| "y": y, |
| }, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, |
| "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseReleased", |
| "button": "left", |
| "clickCount": 1, |
| "x": x, |
| "y": y, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| func ClickByNodeID(ctx context.Context, nodeID int64) error { |
| |
| x, y, err := getElementCenter(ctx, nodeID) |
| if err != nil { |
| return err |
| } |
|
|
| return chromedp.Run(ctx, |
| |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.focus", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mousePressed", |
| "button": "left", |
| "clickCount": 1, |
| "x": x, "y": y, |
| }, nil) |
| }), |
| |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseReleased", |
| "button": "left", |
| "clickCount": 1, |
| "x": x, "y": y, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| func DoubleClickByCoordinate(ctx context.Context, x, y float64) error { |
| if x < 0 || y < 0 { |
| return fmt.Errorf("x/y coordinates must be >= 0") |
| } |
|
|
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, |
| "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mousePressed", |
| "button": "left", |
| "clickCount": 2, |
| "x": x, |
| "y": y, |
| }, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, |
| "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseReleased", |
| "button": "left", |
| "clickCount": 2, |
| "x": x, |
| "y": y, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| func DoubleClickByNodeID(ctx context.Context, nodeID int64) error { |
| x, y, err := getElementCenter(ctx, nodeID) |
| if err != nil { |
| return err |
| } |
|
|
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.focus", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mousePressed", |
| "button": "left", |
| "clickCount": 2, |
| "x": x, "y": y, |
| }, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseReleased", |
| "button": "left", |
| "clickCount": 2, |
| "x": x, "y": y, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| |
| func getElementCenter(ctx context.Context, backendNodeID int64) (x, y float64, err error) { |
| var result json.RawMessage |
| err = chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.getBoxModel", map[string]any{ |
| "backendNodeId": backendNodeID, |
| }, &result) |
| })) |
| if err != nil { |
| return 0, 0, err |
| } |
|
|
| |
| |
| var box struct { |
| Model struct { |
| Content []float64 `json:"content"` |
| } `json:"model"` |
| } |
| if err = json.Unmarshal(result, &box); err != nil { |
| return 0, 0, err |
| } |
|
|
| if len(box.Model.Content) < 4 { |
| return 0, 0, fmt.Errorf("invalid box model: expected at least 4 coordinates") |
| } |
|
|
| |
| |
| x = (box.Model.Content[0] + box.Model.Content[2] + box.Model.Content[4] + box.Model.Content[6]) / 4 |
| y = (box.Model.Content[1] + box.Model.Content[3] + box.Model.Content[5] + box.Model.Content[7]) / 4 |
|
|
| |
| |
| |
| if x == 0 && y == 0 { |
| return getElementCenterJS(ctx, backendNodeID) |
| } |
|
|
| return x, y, nil |
| } |
|
|
| |
| |
| |
| func getElementCenterJS(ctx context.Context, backendNodeID int64) (float64, float64, error) { |
| var resolveResult json.RawMessage |
| if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.resolveNode", map[string]any{ |
| "backendNodeId": backendNodeID, |
| }, &resolveResult) |
| })); err != nil { |
| return 0, 0, fmt.Errorf("DOM.resolveNode: %w", err) |
| } |
|
|
| var resolved struct { |
| Object struct { |
| ObjectID string `json:"objectId"` |
| } `json:"object"` |
| } |
| if err := json.Unmarshal(resolveResult, &resolved); err != nil { |
| return 0, 0, err |
| } |
| if resolved.Object.ObjectID == "" { |
| return 0, 0, fmt.Errorf("element not found in DOM (backendNodeId=%d)", backendNodeID) |
| } |
|
|
| const rectFn = `function() { |
| var r = this.getBoundingClientRect(); |
| return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; |
| }` |
|
|
| var callResult json.RawMessage |
| if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Runtime.callFunctionOn", map[string]any{ |
| "functionDeclaration": rectFn, |
| "objectId": resolved.Object.ObjectID, |
| "returnByValue": true, |
| }, &callResult) |
| })); err != nil { |
| return 0, 0, fmt.Errorf("getBoundingClientRect: %w", err) |
| } |
|
|
| var callRes struct { |
| Result struct { |
| Value struct { |
| X float64 `json:"x"` |
| Y float64 `json:"y"` |
| } `json:"value"` |
| } `json:"result"` |
| } |
| if err := json.Unmarshal(callResult, &callRes); err != nil { |
| return 0, 0, err |
| } |
|
|
| return callRes.Result.Value.X, callRes.Result.Value.Y, nil |
| } |
|
|
| |
| func DragByNodeID(ctx context.Context, nodeID int64, dx, dy int) error { |
| x, y, err := getElementCenter(ctx, nodeID) |
| if err != nil { |
| return err |
| } |
|
|
| endX := x + float64(dx) |
| endY := y + float64(dy) |
|
|
| |
| |
| dist := math.Sqrt(float64(dx*dx + dy*dy)) |
| steps := int(dist / 10) |
| if steps < 5 { |
| steps = 5 |
| } |
| if steps > 40 { |
| steps = 40 |
| } |
|
|
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseMoved", |
| "x": x, "y": y, |
| }, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mousePressed", |
| "button": "left", |
| "clickCount": 1, |
| "x": x, "y": y, |
| }, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| for i := 1; i <= steps; i++ { |
| t := float64(i) / float64(steps) |
| mx := x + t*float64(dx) |
| my := y + t*float64(dy) |
| if err := chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseMoved", |
| "buttons": 1, |
| "x": mx, "y": my, |
| }, nil); err != nil { |
| return err |
| } |
| } |
| return nil |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseReleased", |
| "button": "left", |
| "clickCount": 1, |
| "x": endX, "y": endY, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| |
| |
| |
| var namedKeyDefs = map[string]struct { |
| code string |
| virtualKey int64 |
| insertText string |
| }{ |
| "Enter": {"Enter", 13, "\r"}, |
| "Return": {"Enter", 13, "\r"}, |
| "Tab": {"Tab", 9, "\t"}, |
| "Escape": {"Escape", 27, ""}, |
| "Backspace": {"Backspace", 8, ""}, |
| "Delete": {"Delete", 46, ""}, |
| "ArrowLeft": {"ArrowLeft", 37, ""}, |
| "ArrowRight": {"ArrowRight", 39, ""}, |
| "ArrowUp": {"ArrowUp", 38, ""}, |
| "ArrowDown": {"ArrowDown", 40, ""}, |
| "Home": {"Home", 36, ""}, |
| "End": {"End", 35, ""}, |
| "PageUp": {"PageUp", 33, ""}, |
| "PageDown": {"PageDown", 34, ""}, |
| "Insert": {"Insert", 45, ""}, |
| "F1": {"F1", 112, ""}, |
| "F2": {"F2", 113, ""}, |
| "F3": {"F3", 114, ""}, |
| "F4": {"F4", 115, ""}, |
| "F5": {"F5", 116, ""}, |
| "F6": {"F6", 117, ""}, |
| "F7": {"F7", 118, ""}, |
| "F8": {"F8", 119, ""}, |
| "F9": {"F9", 120, ""}, |
| "F10": {"F10", 121, ""}, |
| "F11": {"F11", 122, ""}, |
| "F12": {"F12", 123, ""}, |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| func DispatchNamedKey(ctx context.Context, key string) error { |
| def, ok := namedKeyDefs[key] |
| if !ok { |
| return chromedp.Run(ctx, chromedp.KeyEvent(key)) |
| } |
|
|
| |
| w3cKey := key |
| if key == "Return" { |
| w3cKey = "Enter" |
| } |
|
|
| dispatchEvent := func(evType string) chromedp.ActionFunc { |
| return chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchKeyEvent", map[string]any{ |
| "type": evType, |
| "key": w3cKey, |
| "code": def.code, |
| "windowsVirtualKeyCode": def.virtualKey, |
| "nativeVirtualKeyCode": def.virtualKey, |
| }, nil) |
| }) |
| } |
|
|
| actions := chromedp.Tasks{dispatchEvent("rawKeyDown")} |
| if def.insertText != "" { |
| insertText := def.insertText |
| actions = append(actions, chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.insertText", map[string]any{ |
| "text": insertText, |
| }, nil) |
| })) |
| } |
| actions = append(actions, dispatchEvent("keyUp")) |
|
|
| return chromedp.Run(ctx, actions...) |
| } |
|
|
| func TypeByNodeID(ctx context.Context, nodeID int64, text string) error { |
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.focus", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| chromedp.KeyEvent(text), |
| ) |
| } |
|
|
| func HoverByCoordinate(ctx context.Context, x, y float64) error { |
| if x < 0 || y < 0 { |
| return fmt.Errorf("x/y coordinates must be >= 0") |
| } |
|
|
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseMoved", |
| "x": x, |
| "y": y, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| func HoverByNodeID(ctx context.Context, nodeID int64) error { |
| |
| x, y, err := getElementCenter(ctx, nodeID) |
| if err != nil { |
| return err |
| } |
|
|
| return chromedp.Run(ctx, |
| |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Input.dispatchMouseEvent", map[string]any{ |
| "type": "mouseMoved", |
| "x": x, "y": y, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| func FillByNodeID(ctx context.Context, nodeID int64, value string) error { |
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.focus", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| var result json.RawMessage |
| if err := chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.resolveNode", map[string]any{ |
| "backendNodeId": nodeID, |
| }, &result); err != nil { |
| return err |
| } |
| var resolved struct { |
| Object struct { |
| ObjectID string `json:"objectId"` |
| } `json:"object"` |
| } |
| if err := json.Unmarshal(result, &resolved); err != nil { |
| return err |
| } |
| js := `function(v) { this.value = v; this.dispatchEvent(new Event('input', {bubbles: true})); this.dispatchEvent(new Event('change', {bubbles: true})); }` |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Runtime.callFunctionOn", map[string]any{ |
| "functionDeclaration": js, |
| "objectId": resolved.Object.ObjectID, |
| "arguments": []map[string]any{{"value": value}}, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| func SelectByNodeID(ctx context.Context, nodeID int64, value string) error { |
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.focus", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| var result json.RawMessage |
| if err := chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.resolveNode", map[string]any{ |
| "backendNodeId": nodeID, |
| }, &result); err != nil { |
| return err |
| } |
| var resolved struct { |
| Object struct { |
| ObjectID string `json:"objectId"` |
| } `json:"object"` |
| } |
| if err := json.Unmarshal(result, &resolved); err != nil { |
| return err |
| } |
| js := `function(v) { this.value = v; this.dispatchEvent(new Event('input', {bubbles: true})); this.dispatchEvent(new Event('change', {bubbles: true})); }` |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "Runtime.callFunctionOn", map[string]any{ |
| "functionDeclaration": js, |
| "objectId": resolved.Object.ObjectID, |
| "arguments": []map[string]any{{"value": value}}, |
| }, nil) |
| }), |
| ) |
| } |
|
|
| func ScrollByNodeID(ctx context.Context, nodeID int64) error { |
| return chromedp.Run(ctx, |
| chromedp.ActionFunc(func(ctx context.Context) error { |
| return chromedp.FromContext(ctx).Target.Execute(ctx, "DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": nodeID}, nil) |
| }), |
| ) |
| } |
|
|
| func WaitForTitle(ctx context.Context, timeout time.Duration) (string, error) { |
| if timeout <= 0 { |
| var title string |
| if err := chromedp.Run(ctx, chromedp.Title(&title)); err != nil { |
| return "", err |
| } |
| return title, nil |
| } |
|
|
| deadline := time.After(timeout) |
| ticker := time.NewTicker(200 * time.Millisecond) |
| defer ticker.Stop() |
|
|
| for { |
| select { |
| case <-ctx.Done(): |
| return "", ctx.Err() |
| case <-deadline: |
| var title string |
| if err := chromedp.Run(ctx, chromedp.Title(&title)); err != nil { |
| return "", err |
| } |
| return title, nil |
| case <-ticker.C: |
| var title string |
| if err := chromedp.Run(ctx, chromedp.Title(&title)); err != nil { |
| continue |
| } |
| if title != "" && title != "about:blank" { |
| return title, nil |
| } |
| } |
| } |
| } |
|
|