mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-09 16:53:59 +02:00
1062 lines
28 KiB
Go
1062 lines
28 KiB
Go
package rod
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod/lib/cdp"
|
|
"github.com/go-rod/rod/lib/devices"
|
|
"github.com/go-rod/rod/lib/js"
|
|
"github.com/go-rod/rod/lib/proto"
|
|
"github.com/go-rod/rod/lib/utils"
|
|
"github.com/ysmood/goob"
|
|
"github.com/ysmood/got/lib/lcs"
|
|
"github.com/ysmood/gson"
|
|
)
|
|
|
|
// Page implements these interfaces.
|
|
var (
|
|
_ proto.Client = &Page{}
|
|
_ proto.Contextable = &Page{}
|
|
_ proto.Sessionable = &Page{}
|
|
)
|
|
|
|
// Page represents the webpage.
|
|
// We try to hold as less states as possible.
|
|
// When a page is closed by Rod or not all the ongoing operations an events on it will abort.
|
|
type Page struct {
|
|
// TargetID is a unique ID for a remote page.
|
|
// It's usually used in events sent from the browser to tell which page an event belongs to.
|
|
TargetID proto.TargetTargetID
|
|
|
|
// FrameID is a unique ID for a browsing context.
|
|
// Usually, different FrameID means different javascript execution context.
|
|
// Such as an iframe and the page it belongs to will have the same TargetID but different FrameIDs.
|
|
FrameID proto.PageFrameID
|
|
|
|
// SessionID is a unique ID for a page attachment to a controller.
|
|
// It's usually used in transport layer to tell which page to send the control signal.
|
|
// A page can attached to multiple controllers, the browser uses it distinguish controllers.
|
|
SessionID proto.TargetSessionID
|
|
|
|
e eFunc
|
|
|
|
ctx context.Context
|
|
|
|
// Used to abort all ongoing actions when a page closes.
|
|
sessionCancel func()
|
|
|
|
root *Page
|
|
|
|
sleeper func() utils.Sleeper
|
|
|
|
browser *Browser
|
|
event *goob.Observable
|
|
|
|
// devices
|
|
Mouse *Mouse
|
|
Keyboard *Keyboard
|
|
Touch *Touch
|
|
|
|
element *Element // iframe only
|
|
|
|
jsCtxLock *sync.Mutex
|
|
jsCtxID *proto.RuntimeRemoteObjectID // use pointer so that page clones can share the change
|
|
helpersLock *sync.Mutex
|
|
helpers map[proto.RuntimeRemoteObjectID]map[string]proto.RuntimeRemoteObjectID
|
|
}
|
|
|
|
// String interface.
|
|
func (p *Page) String() string {
|
|
id := p.TargetID
|
|
if len(id) > 8 {
|
|
id = id[:8]
|
|
}
|
|
return fmt.Sprintf("<page:%s>", id)
|
|
}
|
|
|
|
// IsIframe tells if it's iframe.
|
|
func (p *Page) IsIframe() bool {
|
|
return p.element != nil
|
|
}
|
|
|
|
// GetSessionID interface.
|
|
func (p *Page) GetSessionID() proto.TargetSessionID {
|
|
return p.SessionID
|
|
}
|
|
|
|
// Browser of the page.
|
|
func (p *Page) Browser() *Browser {
|
|
return p.browser
|
|
}
|
|
|
|
// Info of the page, such as the URL or title of the page.
|
|
func (p *Page) Info() (*proto.TargetTargetInfo, error) {
|
|
return p.browser.pageInfo(p.TargetID)
|
|
}
|
|
|
|
// HTML of the page.
|
|
func (p *Page) HTML() (string, error) {
|
|
el, err := p.Element("html")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return el.HTML()
|
|
}
|
|
|
|
// Cookies returns the page cookies. By default it will return the cookies for current page.
|
|
// The urls is the list of URLs for which applicable cookies will be fetched.
|
|
func (p *Page) Cookies(urls []string) ([]*proto.NetworkCookie, error) {
|
|
if len(urls) == 0 {
|
|
info, err := p.Info()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
urls = []string{info.URL}
|
|
}
|
|
|
|
res, err := proto.NetworkGetCookies{Urls: urls}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.Cookies, nil
|
|
}
|
|
|
|
// SetCookies is similar to Browser.SetCookies .
|
|
func (p *Page) SetCookies(cookies []*proto.NetworkCookieParam) error {
|
|
if cookies == nil {
|
|
return proto.NetworkClearBrowserCookies{}.Call(p)
|
|
}
|
|
return proto.NetworkSetCookies{Cookies: cookies}.Call(p)
|
|
}
|
|
|
|
// SetExtraHeaders whether to always send extra HTTP headers with the requests from this page.
|
|
func (p *Page) SetExtraHeaders(dict []string) (func(), error) {
|
|
headers := proto.NetworkHeaders{}
|
|
|
|
for i := 0; i < len(dict); i += 2 {
|
|
headers[dict[i]] = gson.New(dict[i+1])
|
|
}
|
|
|
|
return p.EnableDomain(&proto.NetworkEnable{}), proto.NetworkSetExtraHTTPHeaders{Headers: headers}.Call(p)
|
|
}
|
|
|
|
// SetUserAgent (browser brand, accept-language, etc) of the page.
|
|
// If req is nil, a default user agent will be used, a typical mac chrome.
|
|
func (p *Page) SetUserAgent(req *proto.NetworkSetUserAgentOverride) error {
|
|
if req == nil {
|
|
req = devices.LaptopWithMDPIScreen.UserAgentEmulation()
|
|
}
|
|
return req.Call(p)
|
|
}
|
|
|
|
// SetBlockedURLs For some requests that do not want to be triggered,
|
|
// such as some dangerous operations, delete, quit logout, etc.
|
|
// Wildcards ('*') are allowed, such as ["*/api/logout/*","delete"].
|
|
// NOTE: if you set empty pattern "", it will block all requests.
|
|
func (p *Page) SetBlockedURLs(urls []string) error {
|
|
if len(urls) == 0 {
|
|
return nil
|
|
}
|
|
return proto.NetworkSetBlockedURLs{Urls: urls}.Call(p)
|
|
}
|
|
|
|
// Navigate to the url. If the url is empty, "about:blank" will be used.
|
|
// It will return immediately after the server responds the http header.
|
|
func (p *Page) Navigate(url string) error {
|
|
if url == "" {
|
|
url = "about:blank"
|
|
}
|
|
|
|
// try to stop loading
|
|
_ = p.StopLoading()
|
|
|
|
res, err := proto.PageNavigate{URL: url}.Call(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res.ErrorText != "" {
|
|
return &NavigationError{res.ErrorText}
|
|
}
|
|
|
|
p.root.unsetJSCtxID()
|
|
|
|
return nil
|
|
}
|
|
|
|
// NavigateBack history.
|
|
func (p *Page) NavigateBack() error {
|
|
// Not using cdp API because it doesn't work for iframe
|
|
_, err := p.Evaluate(Eval(`() => history.back()`).ByUser())
|
|
return err
|
|
}
|
|
|
|
// ResetNavigationHistory reset history.
|
|
func (p *Page) ResetNavigationHistory() error {
|
|
err := proto.PageResetNavigationHistory{}.Call(p)
|
|
return err
|
|
}
|
|
|
|
// GetNavigationHistory get navigation history.
|
|
func (p *Page) GetNavigationHistory() (*proto.PageGetNavigationHistoryResult, error) {
|
|
return proto.PageGetNavigationHistory{}.Call(p)
|
|
}
|
|
|
|
// NavigateForward history.
|
|
func (p *Page) NavigateForward() error {
|
|
// Not using cdp API because it doesn't work for iframe
|
|
_, err := p.Evaluate(Eval(`() => history.forward()`).ByUser())
|
|
return err
|
|
}
|
|
|
|
// Reload page.
|
|
func (p *Page) Reload() error {
|
|
p, cancel := p.WithCancel()
|
|
defer cancel()
|
|
|
|
wait := p.EachEvent(func(e *proto.PageFrameNavigated) bool {
|
|
return e.Frame.ID == p.FrameID
|
|
})
|
|
|
|
// Not using cdp API because it doesn't work for iframe
|
|
_, err := p.Evaluate(Eval(`() => location.reload()`).ByUser())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
wait()
|
|
|
|
p.unsetJSCtxID()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Activate (focuses) the page.
|
|
func (p *Page) Activate() (*Page, error) {
|
|
err := proto.TargetActivateTarget{TargetID: p.TargetID}.Call(p.browser)
|
|
return p, err
|
|
}
|
|
|
|
func (p *Page) getWindowID() (proto.BrowserWindowID, error) {
|
|
res, err := proto.BrowserGetWindowForTarget{TargetID: p.TargetID}.Call(p)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.WindowID, err
|
|
}
|
|
|
|
// GetWindow position and size info.
|
|
func (p *Page) GetWindow() (*proto.BrowserBounds, error) {
|
|
id, err := p.getWindowID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res, err := proto.BrowserGetWindowBounds{WindowID: id}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return res.Bounds, nil
|
|
}
|
|
|
|
// SetWindow location and size.
|
|
func (p *Page) SetWindow(bounds *proto.BrowserBounds) error {
|
|
id, err := p.getWindowID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = proto.BrowserSetWindowBounds{WindowID: id, Bounds: bounds}.Call(p)
|
|
return err
|
|
}
|
|
|
|
// SetViewport overrides the values of device screen dimensions.
|
|
func (p *Page) SetViewport(params *proto.EmulationSetDeviceMetricsOverride) error {
|
|
if params == nil {
|
|
return proto.EmulationClearDeviceMetricsOverride{}.Call(p)
|
|
}
|
|
return params.Call(p)
|
|
}
|
|
|
|
// SetDocumentContent sets the page document html content.
|
|
func (p *Page) SetDocumentContent(html string) error {
|
|
return proto.PageSetDocumentContent{
|
|
FrameID: p.FrameID,
|
|
HTML: html,
|
|
}.Call(p)
|
|
}
|
|
|
|
// Emulate the device, such as iPhone9. If device is devices.Clear, it will clear the override.
|
|
func (p *Page) Emulate(device devices.Device) error {
|
|
err := p.SetViewport(device.MetricsEmulation())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = device.TouchEmulation().Call(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return p.SetUserAgent(device.UserAgentEmulation())
|
|
}
|
|
|
|
// StopLoading forces the page stop navigation and pending resource fetches.
|
|
func (p *Page) StopLoading() error {
|
|
return proto.PageStopLoading{}.Call(p)
|
|
}
|
|
|
|
// Close tries to close page, running its beforeunload hooks, if has any.
|
|
func (p *Page) Close() error {
|
|
p.browser.targetsLock.Lock()
|
|
defer p.browser.targetsLock.Unlock()
|
|
|
|
success := true
|
|
ctx, cancel := context.WithCancel(p.ctx)
|
|
defer cancel()
|
|
messages := p.browser.Context(ctx).Event()
|
|
|
|
for {
|
|
err := proto.PageClose{}.Call(p)
|
|
if errors.Is(err, cdp.ErrNotAttachedToActivePage) {
|
|
// TODO: I don't know why chromium doesn't allow us to close a page while it's navigating.
|
|
// Looks like a bug in chromium.
|
|
utils.Sleep(0.1)
|
|
continue
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
break
|
|
}
|
|
|
|
for msg := range messages {
|
|
stop := false
|
|
|
|
destroyed := proto.TargetTargetDestroyed{}
|
|
closed := proto.PageJavascriptDialogClosed{}
|
|
if msg.Load(&destroyed) {
|
|
stop = destroyed.TargetID == p.TargetID
|
|
} else if msg.SessionID == p.SessionID && msg.Load(&closed) {
|
|
success = closed.Result
|
|
stop = !success
|
|
}
|
|
|
|
if stop {
|
|
break
|
|
}
|
|
}
|
|
|
|
if success {
|
|
p.cleanupStates()
|
|
} else {
|
|
return &PageCloseCanceledError{}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TriggerFavicon supports when browser in headless mode
|
|
// to trigger favicon's request. Pay attention to this
|
|
// function only supported when browser in headless mode,
|
|
// if you call it in no-headless mode, it will raise an error
|
|
// with the message "browser is no-headless".
|
|
func (p *Page) TriggerFavicon() error {
|
|
// check if browser whether in headless mode
|
|
// if not in headless mode then raise error
|
|
if !p.browser.isHeadless() {
|
|
return errors.New("browser is no-headless")
|
|
}
|
|
|
|
_, err := p.Evaluate(evalHelper(js.TriggerFavicon).ByPromise())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HandleDialog accepts or dismisses next JavaScript initiated dialog (alert, confirm, prompt, or onbeforeunload).
|
|
// Because modal dialog will block js, usually you have to trigger the dialog in another goroutine.
|
|
// For example:
|
|
//
|
|
// wait, handle := page.MustHandleDialog()
|
|
// go page.MustElement("button").MustClick()
|
|
// wait()
|
|
// handle(true, "")
|
|
func (p *Page) HandleDialog() (
|
|
wait func() *proto.PageJavascriptDialogOpening,
|
|
handle func(*proto.PageHandleJavaScriptDialog) error,
|
|
) {
|
|
restore := p.EnableDomain(&proto.PageEnable{})
|
|
|
|
var e proto.PageJavascriptDialogOpening
|
|
w := p.WaitEvent(&e)
|
|
|
|
return func() *proto.PageJavascriptDialogOpening {
|
|
w()
|
|
return &e
|
|
}, func(h *proto.PageHandleJavaScriptDialog) error {
|
|
defer restore()
|
|
return h.Call(p)
|
|
}
|
|
}
|
|
|
|
// HandleFileDialog return a functions that waits for the next file chooser dialog pops up and returns the element
|
|
// for the event.
|
|
func (p *Page) HandleFileDialog() (func([]string) error, error) {
|
|
err := proto.PageSetInterceptFileChooserDialog{Enabled: true}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var e proto.PageFileChooserOpened
|
|
w := p.WaitEvent(&e)
|
|
|
|
return func(paths []string) error {
|
|
w()
|
|
|
|
err := proto.PageSetInterceptFileChooserDialog{Enabled: false}.Call(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return proto.DOMSetFileInputFiles{
|
|
Files: utils.AbsolutePaths(paths),
|
|
BackendNodeID: e.BackendNodeID,
|
|
}.Call(p)
|
|
}, nil
|
|
}
|
|
|
|
// Screenshot captures the screenshot of current page.
|
|
func (p *Page) Screenshot(fullPage bool, req *proto.PageCaptureScreenshot) ([]byte, error) {
|
|
if req == nil {
|
|
req = &proto.PageCaptureScreenshot{}
|
|
}
|
|
if fullPage {
|
|
metrics, err := proto.PageGetLayoutMetrics{}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if metrics.CSSContentSize == nil {
|
|
return nil, errors.New("failed to get css content size")
|
|
}
|
|
|
|
oldView := proto.EmulationSetDeviceMetricsOverride{}
|
|
set := p.LoadState(&oldView)
|
|
view := oldView
|
|
view.Width = int(metrics.CSSContentSize.Width)
|
|
view.Height = int(metrics.CSSContentSize.Height)
|
|
|
|
err = p.SetViewport(&view)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() { // try to recover the viewport
|
|
if !set {
|
|
_ = proto.EmulationClearDeviceMetricsOverride{}.Call(p)
|
|
return
|
|
}
|
|
|
|
_ = p.SetViewport(&oldView)
|
|
}()
|
|
}
|
|
|
|
shot, err := req.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return shot.Data, nil
|
|
}
|
|
|
|
// ScrollScreenshotOptions is the options for the ScrollScreenshot.
|
|
type ScrollScreenshotOptions struct {
|
|
// Format (optional) Image compression format (defaults to png).
|
|
Format proto.PageCaptureScreenshotFormat `json:"format,omitempty"`
|
|
|
|
// Quality (optional) Compression quality from range [0..100] (jpeg only).
|
|
Quality *int `json:"quality,omitempty"`
|
|
|
|
// FixedTop (optional) The number of pixels to skip from the top.
|
|
// It is suitable for optimizing the screenshot effect when there is a fixed
|
|
// positioning element at the top of the page.
|
|
FixedTop float64
|
|
|
|
// FixedBottom (optional) The number of pixels to skip from the bottom.
|
|
FixedBottom float64
|
|
|
|
// WaitPerScroll until no animation (default is 300ms)
|
|
WaitPerScroll time.Duration
|
|
}
|
|
|
|
// ScrollScreenshot Scroll screenshot does not adjust the size of the viewport,
|
|
// but achieves it by scrolling and capturing screenshots in a loop, and then stitching them together.
|
|
// Note that this method also has a flaw: when there are elements with fixed
|
|
// positioning on the page (usually header navigation components),
|
|
// these elements will appear repeatedly, you can set the FixedTop parameter to optimize it.
|
|
//
|
|
// Only support png and jpeg format yet, webP is not supported because no suitable processing
|
|
// library was found in golang.
|
|
func (p *Page) ScrollScreenshot(opt *ScrollScreenshotOptions) ([]byte, error) {
|
|
if opt == nil {
|
|
opt = &ScrollScreenshotOptions{}
|
|
}
|
|
if opt.WaitPerScroll == 0 {
|
|
opt.WaitPerScroll = time.Millisecond * 300
|
|
}
|
|
|
|
metrics, err := proto.PageGetLayoutMetrics{}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if metrics.CSSContentSize == nil || metrics.CSSVisualViewport == nil {
|
|
return nil, errors.New("failed to get css content size")
|
|
}
|
|
|
|
viewpointHeight := metrics.CSSVisualViewport.ClientHeight
|
|
contentHeight := metrics.CSSContentSize.Height
|
|
|
|
var scrollTop float64
|
|
var images []utils.ImgWithBox
|
|
|
|
for {
|
|
clip := &proto.PageViewport{
|
|
X: 0,
|
|
Y: scrollTop,
|
|
Width: metrics.CSSVisualViewport.ClientWidth,
|
|
Scale: 1,
|
|
}
|
|
|
|
scrollY := viewpointHeight - (opt.FixedTop + opt.FixedBottom)
|
|
if scrollTop+viewpointHeight > contentHeight {
|
|
clip.Height = contentHeight - scrollTop
|
|
} else {
|
|
clip.Height = scrollY
|
|
if scrollTop != 0 {
|
|
clip.Y += opt.FixedTop
|
|
}
|
|
}
|
|
|
|
req := &proto.PageCaptureScreenshot{
|
|
Format: opt.Format,
|
|
Quality: opt.Quality,
|
|
Clip: clip,
|
|
FromSurface: false,
|
|
CaptureBeyondViewport: false,
|
|
OptimizeForSpeed: false,
|
|
}
|
|
shot, err := req.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
images = append(images, utils.ImgWithBox{Img: shot.Data})
|
|
|
|
scrollTop += scrollY
|
|
if scrollTop >= contentHeight {
|
|
break
|
|
}
|
|
|
|
err = p.Mouse.Scroll(0, scrollY, 1)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scroll error: %w", err)
|
|
}
|
|
|
|
err = p.WaitDOMStable(opt.WaitPerScroll, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("WaitDOMStable error: %w", err)
|
|
}
|
|
}
|
|
|
|
var imgOption *utils.ImgOption
|
|
if opt.Quality != nil {
|
|
imgOption = &utils.ImgOption{
|
|
Quality: *opt.Quality,
|
|
}
|
|
}
|
|
bs, err := utils.SplicePngVertical(images, opt.Format, imgOption)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return bs, nil
|
|
}
|
|
|
|
// CaptureDOMSnapshot Returns a document snapshot, including the full DOM tree of the root node
|
|
// (including iframes, template contents, and imported documents) in a flattened array,
|
|
// as well as layout and white-listed computed style information for the nodes.
|
|
// Shadow DOM in the returned DOM tree is flattened.
|
|
// `Documents` The nodes in the DOM tree. The DOMNode at index 0 corresponds to the root document.
|
|
// `Strings` Shared string table that all string properties refer to with indexes.
|
|
// Normally use `Strings` is enough.
|
|
func (p *Page) CaptureDOMSnapshot() (domSnapshot *proto.DOMSnapshotCaptureSnapshotResult, err error) {
|
|
_ = proto.DOMSnapshotEnable{}.Call(p)
|
|
|
|
snapshot, err := proto.DOMSnapshotCaptureSnapshot{
|
|
ComputedStyles: []string{},
|
|
IncludePaintOrder: true,
|
|
IncludeDOMRects: true,
|
|
IncludeBlendedBackgroundColors: true,
|
|
IncludeTextColorOpacities: true,
|
|
}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
// PDF prints page as PDF.
|
|
func (p *Page) PDF(req *proto.PagePrintToPDF) (*StreamReader, error) {
|
|
req.TransferMode = proto.PagePrintToPDFTransferModeReturnAsStream
|
|
res, err := req.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return NewStreamReader(p, res.Stream), nil
|
|
}
|
|
|
|
// GetResource content by the url. Such as image, css, html, etc.
|
|
// Use the [proto.PageGetResourceTree] to list all the resources.
|
|
func (p *Page) GetResource(url string) ([]byte, error) {
|
|
res, err := proto.PageGetResourceContent{
|
|
FrameID: p.FrameID,
|
|
URL: url,
|
|
}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data := res.Content
|
|
|
|
var bin []byte
|
|
if res.Base64Encoded {
|
|
bin, err = base64.StdEncoding.DecodeString(data)
|
|
utils.E(err)
|
|
} else {
|
|
bin = []byte(data)
|
|
}
|
|
|
|
return bin, nil
|
|
}
|
|
|
|
// WaitOpen waits for the next new page opened by the current one.
|
|
func (p *Page) WaitOpen() func() (*Page, error) {
|
|
var targetID proto.TargetTargetID
|
|
|
|
b := p.browser.Context(p.ctx)
|
|
wait := b.EachEvent(func(e *proto.TargetTargetCreated) bool {
|
|
targetID = e.TargetInfo.TargetID
|
|
return e.TargetInfo.OpenerID == p.TargetID
|
|
})
|
|
|
|
return func() (*Page, error) {
|
|
defer p.tryTrace(TraceTypeWait, "wait open")()
|
|
wait()
|
|
return b.PageFromTarget(targetID)
|
|
}
|
|
}
|
|
|
|
// EachEvent of the specified event types, if any callback returns true the wait function will resolve,
|
|
// The type of each callback is (? means optional):
|
|
//
|
|
// func(proto.Event, proto.TargetSessionID?) bool?
|
|
//
|
|
// You can listen to multiple event types at the same time like:
|
|
//
|
|
// browser.EachEvent(func(a *proto.A) {}, func(b *proto.B) {})
|
|
//
|
|
// Such as subscribe the events to know when the navigation is complete or when the page is rendered.
|
|
// Here's an example to dismiss all dialogs/alerts on the page:
|
|
//
|
|
// go page.EachEvent(func(e *proto.PageJavascriptDialogOpening) {
|
|
// _ = proto.PageHandleJavaScriptDialog{ Accept: false, PromptText: ""}.Call(page)
|
|
// })()
|
|
func (p *Page) EachEvent(callbacks ...interface{}) (wait func()) {
|
|
return p.browser.Context(p.ctx).eachEvent(p.SessionID, callbacks...)
|
|
}
|
|
|
|
// WaitEvent waits for the next event for one time. It will also load the data into the event object.
|
|
func (p *Page) WaitEvent(e proto.Event) (wait func()) {
|
|
defer p.tryTrace(TraceTypeWait, "event", e.ProtoEvent())()
|
|
return p.browser.Context(p.ctx).waitEvent(p.SessionID, e)
|
|
}
|
|
|
|
// WaitNavigation wait for a page lifecycle event when navigating.
|
|
// Usually you will wait for [proto.PageLifecycleEventNameNetworkAlmostIdle].
|
|
func (p *Page) WaitNavigation(name proto.PageLifecycleEventName) func() {
|
|
_ = proto.PageSetLifecycleEventsEnabled{Enabled: true}.Call(p)
|
|
|
|
wait := p.EachEvent(func(e *proto.PageLifecycleEvent) bool {
|
|
return e.Name == name
|
|
})
|
|
|
|
return func() {
|
|
defer p.tryTrace(TraceTypeWait, "navigation", name)()
|
|
wait()
|
|
_ = proto.PageSetLifecycleEventsEnabled{Enabled: false}.Call(p)
|
|
}
|
|
}
|
|
|
|
// WaitRequestIdle returns a wait function that waits until no request for d duration.
|
|
// Be careful, d is not the max wait timeout, it's the least idle time.
|
|
// If you want to set a timeout you can use the [Page.Timeout] function.
|
|
// Use the includes and excludes regexp list to filter the requests by their url.
|
|
func (p *Page) WaitRequestIdle(
|
|
d time.Duration,
|
|
includes, excludes []string,
|
|
excludeTypes []proto.NetworkResourceType,
|
|
) func() {
|
|
defer p.tryTrace(TraceTypeWait, "request-idle")()
|
|
|
|
if excludeTypes == nil {
|
|
excludeTypes = []proto.NetworkResourceType{
|
|
proto.NetworkResourceTypeWebSocket,
|
|
proto.NetworkResourceTypeEventSource,
|
|
proto.NetworkResourceTypeMedia,
|
|
proto.NetworkResourceTypeImage,
|
|
proto.NetworkResourceTypeFont,
|
|
}
|
|
}
|
|
|
|
if len(includes) == 0 {
|
|
includes = []string{""}
|
|
}
|
|
|
|
p, cancel := p.WithCancel()
|
|
match := genRegMatcher(includes, excludes)
|
|
waitList := map[proto.NetworkRequestID]string{}
|
|
idleCounter := utils.NewIdleCounter(d)
|
|
update := p.tryTraceReq(includes, excludes)
|
|
update(nil)
|
|
|
|
checkDone := func(id proto.NetworkRequestID) {
|
|
if _, has := waitList[id]; has {
|
|
delete(waitList, id)
|
|
update(waitList)
|
|
idleCounter.Done()
|
|
}
|
|
}
|
|
|
|
wait := p.EachEvent(func(sent *proto.NetworkRequestWillBeSent) {
|
|
for _, t := range excludeTypes {
|
|
if sent.Type == t {
|
|
return
|
|
}
|
|
}
|
|
|
|
if match(sent.Request.URL) {
|
|
// Redirect will send multiple NetworkRequestWillBeSent events with the same RequestID,
|
|
// we should filter them out.
|
|
if _, has := waitList[sent.RequestID]; !has {
|
|
waitList[sent.RequestID] = sent.Request.URL
|
|
update(waitList)
|
|
idleCounter.Add()
|
|
}
|
|
}
|
|
}, func(e *proto.NetworkLoadingFinished) {
|
|
checkDone(e.RequestID)
|
|
}, func(e *proto.NetworkLoadingFailed) {
|
|
checkDone(e.RequestID)
|
|
})
|
|
|
|
return func() {
|
|
go func() {
|
|
idleCounter.Wait(p.ctx)
|
|
cancel()
|
|
}()
|
|
wait()
|
|
}
|
|
}
|
|
|
|
// WaitDOMStable waits until the change of the DOM tree is less or equal than diff percent for d duration.
|
|
// Be careful, d is not the max wait timeout, it's the least stable time.
|
|
// If you want to set a timeout you can use the [Page.Timeout] function.
|
|
func (p *Page) WaitDOMStable(d time.Duration, diff float64) error {
|
|
defer p.tryTrace(TraceTypeWait, "dom-stable")()
|
|
|
|
domSnapshot, err := p.CaptureDOMSnapshot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
t := time.NewTicker(d)
|
|
defer t.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-t.C:
|
|
case <-p.ctx.Done():
|
|
return p.ctx.Err()
|
|
}
|
|
|
|
currentDomSnapshot, err := p.CaptureDOMSnapshot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
xs := lcs.NewWords(domSnapshot.Strings)
|
|
ys := lcs.NewWords(currentDomSnapshot.Strings)
|
|
lcs := xs.YadLCS(p.ctx, ys)
|
|
|
|
df := 1 - float64(len(lcs))/float64(len(ys))
|
|
if df <= diff {
|
|
break
|
|
}
|
|
|
|
domSnapshot = currentDomSnapshot
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WaitStable waits until the page is stable for d duration.
|
|
func (p *Page) WaitStable(d time.Duration) error {
|
|
defer p.tryTrace(TraceTypeWait, "stable")()
|
|
|
|
var err error
|
|
|
|
setErr := sync.Once{}
|
|
|
|
utils.All(func() {
|
|
e := p.WaitLoad()
|
|
setErr.Do(func() { err = e })
|
|
}, func() {
|
|
p.WaitRequestIdle(d, nil, nil, nil)()
|
|
}, func() {
|
|
e := p.WaitDOMStable(d, 0)
|
|
setErr.Do(func() { err = e })
|
|
})()
|
|
|
|
return err
|
|
}
|
|
|
|
// WaitIdle waits until the next window.requestIdleCallback is called.
|
|
func (p *Page) WaitIdle(timeout time.Duration) (err error) {
|
|
_, err = p.Evaluate(evalHelper(js.WaitIdle, timeout.Milliseconds()).ByPromise())
|
|
return err
|
|
}
|
|
|
|
// WaitRepaint waits until the next repaint.
|
|
// Doc: https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
|
|
func (p *Page) WaitRepaint() error {
|
|
// we use root here because iframe doesn't trigger requestAnimationFrame
|
|
_, err := p.root.Eval(`() => new Promise(r => requestAnimationFrame(r))`)
|
|
return err
|
|
}
|
|
|
|
// WaitLoad waits for the `window.onload` event, it returns immediately if the event is already fired.
|
|
func (p *Page) WaitLoad() error {
|
|
defer p.tryTrace(TraceTypeWait, "load")()
|
|
_, err := p.Evaluate(evalHelper(js.WaitLoad).ByPromise())
|
|
return err
|
|
}
|
|
|
|
// AddScriptTag to page. If url is empty, content will be used.
|
|
func (p *Page) AddScriptTag(url, content string) error {
|
|
hash := md5.Sum([]byte(url + content))
|
|
id := hex.EncodeToString(hash[:])
|
|
_, err := p.Evaluate(evalHelper(js.AddScriptTag, id, url, content).ByPromise())
|
|
return err
|
|
}
|
|
|
|
// AddStyleTag to page. If url is empty, content will be used.
|
|
func (p *Page) AddStyleTag(url, content string) error {
|
|
hash := md5.Sum([]byte(url + content))
|
|
id := hex.EncodeToString(hash[:])
|
|
_, err := p.Evaluate(evalHelper(js.AddStyleTag, id, url, content).ByPromise())
|
|
return err
|
|
}
|
|
|
|
// EvalOnNewDocument Evaluates given script in every frame upon creation (before loading frame's scripts).
|
|
func (p *Page) EvalOnNewDocument(js string) (remove func() error, err error) {
|
|
res, err := proto.PageAddScriptToEvaluateOnNewDocument{Source: js}.Call(p)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
remove = func() error {
|
|
return proto.PageRemoveScriptToEvaluateOnNewDocument{
|
|
Identifier: res.Identifier,
|
|
}.Call(p)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Wait until the js returns true.
|
|
func (p *Page) Wait(opts *EvalOptions) error {
|
|
return utils.Retry(p.ctx, p.sleeper(), func() (bool, error) {
|
|
res, err := p.Evaluate(opts)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
|
|
return res.Value.Bool(), nil
|
|
})
|
|
}
|
|
|
|
// WaitElementsMoreThan waits until there are more than num elements that match the selector.
|
|
func (p *Page) WaitElementsMoreThan(selector string, num int) error {
|
|
return p.Wait(Eval(`(s, n) => document.querySelectorAll(s).length > n`, selector, num))
|
|
}
|
|
|
|
// ObjectToJSON by object id.
|
|
func (p *Page) ObjectToJSON(obj *proto.RuntimeRemoteObject) (gson.JSON, error) {
|
|
if obj.ObjectID == "" {
|
|
return obj.Value, nil
|
|
}
|
|
|
|
res, err := proto.RuntimeCallFunctionOn{
|
|
ObjectID: obj.ObjectID,
|
|
FunctionDeclaration: `function() { return this }`,
|
|
ReturnByValue: true,
|
|
}.Call(p)
|
|
if err != nil {
|
|
return gson.New(nil), err
|
|
}
|
|
return res.Result.Value, nil
|
|
}
|
|
|
|
// ElementFromObject creates an Element from the remote object id.
|
|
func (p *Page) ElementFromObject(obj *proto.RuntimeRemoteObject) (*Element, error) {
|
|
// If the element is in an iframe, we need the jsCtxID to inject helper.js to the correct context.
|
|
id, err := p.jsCtxIDByObjectID(obj.ObjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pid, err := p.getJSCtxID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if id != pid {
|
|
clone := *p
|
|
clone.jsCtxID = &id
|
|
p = &clone
|
|
}
|
|
|
|
return &Element{
|
|
e: p.e,
|
|
ctx: p.ctx,
|
|
sleeper: p.sleeper,
|
|
page: p,
|
|
Object: obj,
|
|
}, nil
|
|
}
|
|
|
|
// ElementFromNode creates an Element from the node, [proto.DOMNodeID] or [proto.DOMBackendNodeID] must be specified.
|
|
func (p *Page) ElementFromNode(node *proto.DOMNode) (*Element, error) {
|
|
res, err := proto.DOMResolveNode{
|
|
NodeID: node.NodeID,
|
|
BackendNodeID: node.BackendNodeID,
|
|
}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
el, err := p.ElementFromObject(res.Object)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// make sure always return an element node
|
|
desc, err := el.Describe(0, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if desc.NodeName == "#text" {
|
|
el, err = el.Parent()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return el, nil
|
|
}
|
|
|
|
// ElementFromPoint creates an Element from the absolute point on the page.
|
|
// The point should include the window scroll offset.
|
|
func (p *Page) ElementFromPoint(x, y int) (*Element, error) {
|
|
node, err := proto.DOMGetNodeForLocation{X: x, Y: y}.Call(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return p.ElementFromNode(&proto.DOMNode{
|
|
BackendNodeID: node.BackendNodeID,
|
|
})
|
|
}
|
|
|
|
// Release the remote object. Usually, you don't need to call it.
|
|
// When a page is closed or reloaded, all remote objects will be released automatically.
|
|
// It's useful if the page never closes or reloads.
|
|
func (p *Page) Release(obj *proto.RuntimeRemoteObject) error {
|
|
err := proto.RuntimeReleaseObject{ObjectID: obj.ObjectID}.Call(p)
|
|
return err
|
|
}
|
|
|
|
// Call implements the [proto.Client].
|
|
func (p *Page) Call(ctx context.Context, sessionID, methodName string, params interface{}) (res []byte, err error) {
|
|
return p.browser.Call(ctx, sessionID, methodName, params)
|
|
}
|
|
|
|
// Event of the page.
|
|
func (p *Page) Event() <-chan *Message {
|
|
dst := make(chan *Message)
|
|
s := p.event.Subscribe(p.ctx)
|
|
|
|
go func() {
|
|
defer close(dst)
|
|
for {
|
|
select {
|
|
case <-p.ctx.Done():
|
|
return
|
|
case msg, ok := <-s:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case <-p.ctx.Done():
|
|
return
|
|
case dst <- msg.(*Message): //nolint: forcetypeassert
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return dst
|
|
}
|
|
|
|
func (p *Page) initEvents() {
|
|
p.event = goob.New(p.ctx)
|
|
event := p.browser.Context(p.ctx).Event()
|
|
|
|
go func() {
|
|
for msg := range event {
|
|
detached := proto.TargetDetachedFromTarget{}
|
|
destroyed := proto.TargetTargetDestroyed{}
|
|
|
|
if (msg.Load(&detached) && detached.SessionID == p.SessionID) ||
|
|
(msg.Load(destroyed) && destroyed.TargetID == p.TargetID) {
|
|
p.sessionCancel()
|
|
return
|
|
}
|
|
|
|
if msg.SessionID != p.SessionID {
|
|
continue
|
|
}
|
|
|
|
p.event.Publish(msg)
|
|
}
|
|
}()
|
|
}
|