├── js_nostealth.go ├── js_stealth.go ├── client.go ├── log.go ├── .cnb.yml ├── devices_test.go ├── vaidator_test.go ├── action ├── json.go ├── utils.go ├── parse.go ├── page_action.go ├── action.go └── auto.go ├── go.mod ├── input.go ├── assist.go ├── js.go ├── request.go ├── vaidator.go ├── example └── base.go ├── element_input.go ├── element_property.go ├── utils.go ├── extension.go ├── hijack.go ├── devices.go ├── element.go ├── go.sum ├── browser.go ├── options.go ├── README.md ├── fluent.go └── page.go /js_nostealth.go: -------------------------------------------------------------------------------- 1 | //go:build nostealth 2 | // +build nostealth 3 | 4 | package browser 5 | 6 | var stealth string 7 | -------------------------------------------------------------------------------- /js_stealth.go: -------------------------------------------------------------------------------- 1 | //go:build !nostealth 2 | // +build !nostealth 3 | 4 | package browser 5 | 6 | import _ "embed" 7 | 8 | //go:embed stealth.min.js 9 | var stealth string 10 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/sohaha/zlsgo/zhttp" 5 | ) 6 | 7 | func (b *Browser) Client() *zhttp.Engine { 8 | return b.client 9 | } 10 | 11 | func (page *Page) Client() *zhttp.Engine { 12 | return page.browser.client 13 | } 14 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import "github.com/sohaha/zlsgo/zlog" 4 | 5 | var Log = zlog.New("") 6 | 7 | func init() { 8 | Log.ResetFlags(zlog.BitLevel | zlog.BitTime) 9 | } 10 | 11 | type Logger struct { 12 | log *zlog.Logger 13 | } 14 | 15 | func newLogger() *Logger { 16 | return &Logger{log: Log} 17 | } 18 | 19 | func (l *Logger) Println(i ...interface{}) { 20 | l.log.Tips(i...) 21 | } 22 | -------------------------------------------------------------------------------- /.cnb.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - https://cnb.cool/zls-tools/vm/-/blob/main/ide.yml 3 | - https://cnb.cool/zls-tools/vm/-/blob/main/tools/sync-github-zlsgo.yml 4 | 5 | $: 6 | vscode: 7 | clouddev: 8 | imports: https://cnb.cool/zls-tools/env/-/blob/main/envs.yml 9 | runner: 10 | tags: cnb:arch:amd64 11 | cpus: 10 12 | push: 13 | sync_github: 14 | env: 15 | GIT_USER: zlsgo -------------------------------------------------------------------------------- /devices_test.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-rod/rod/lib/devices" 7 | "github.com/sohaha/zlsgo" 8 | ) 9 | 10 | func TestRandomDevice(t *testing.T) { 11 | tt := zlsgo.NewTest(t) 12 | 13 | tt.Log(devices.Nexus4.UserAgent) 14 | tt.Log(RandomDevice(DeviceOptions{ 15 | Name: "Chrome", 16 | maxMajorVersion: 112, 17 | minMajorVersion: 100, 18 | maxMinorVersion: 20, 19 | maxPatchVersion: 5000, 20 | }).UserAgent, devices.Nexus4) 21 | } 22 | -------------------------------------------------------------------------------- /vaidator_test.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sohaha/zlsgo" 7 | ) 8 | 9 | func Test_filterElementsRules(t *testing.T) { 10 | tt := zlsgo.NewTest(t) 11 | 12 | rules := filterElementsRules("a,href=xxx.com", "a h2 != xxx.com ") 13 | tt.Equal(len(rules), 2) 14 | 15 | for i, v := range rules { 16 | switch i { 17 | case 0: 18 | tt.Equal(v.selector, "a", true) 19 | tt.Equal(v.attr, "href", true) 20 | tt.Equal(v.isEq, true, true) 21 | tt.Equal(v.value, "xxx.com", true) 22 | case 1: 23 | tt.Equal(v.selector, "a h2", true) 24 | tt.Equal(v.attr, "", true) 25 | tt.Equal(v.isEq, false, true) 26 | tt.Equal(v.value, "xxx.com", true) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /action/json.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/sohaha/zlsgo/zjson" 8 | "github.com/zlsgo/browser" 9 | ) 10 | 11 | func NewAutoFromJson(b *browser.Browser, jsonStr []byte) (*Auto, error) { 12 | j := zjson.ParseBytes(jsonStr) 13 | url := j.Get("url").String() 14 | actions := j.Get("actions").Array() 15 | timeout := j.Get("timeout").Int() 16 | 17 | if url == "" { 18 | return nil, errors.New("url is required") 19 | } 20 | 21 | if len(actions) == 0 { 22 | return nil, errors.New("actions is required") 23 | } 24 | 25 | return &Auto{ 26 | browser: b, 27 | url: url, 28 | actions: parseAction(actions), 29 | timeout: time.Duration(timeout) * time.Second, 30 | }, nil 31 | } 32 | -------------------------------------------------------------------------------- /action/utils.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/zlsgo/browser" 5 | ) 6 | 7 | // ExtractElement 从 Result 里获取元素 8 | func ExtractElement(parentResults ...ActionResult) (*browser.Element, bool) { 9 | if len(parentResults) == 0 || parentResults[0].Value == nil { 10 | return nil, false 11 | } 12 | switch v := parentResults[0].Value.(type) { 13 | case *browser.Element: 14 | return v, true 15 | } 16 | 17 | return nil, false 18 | } 19 | 20 | // ExtractPage 从 Result 里获取页面 21 | func ExtractPage(parentResults ...ActionResult) (*browser.Page, bool) { 22 | if len(parentResults) == 0 || parentResults[0].Value == nil { 23 | return nil, false 24 | } 25 | switch v := parentResults[0].Value.(type) { 26 | case *browser.Page: 27 | return v, true 28 | } 29 | return nil, false 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zlsgo/browser 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/go-rod/rod v0.116.2 7 | github.com/mediabuyerbot/go-crx3 v1.5.1 8 | github.com/sohaha/zlsgo v1.7.19-0.20250821063740-f62f4dffb58d 9 | github.com/ysmood/gson v0.7.3 10 | ) 11 | 12 | require ( 13 | github.com/ysmood/fetchup v0.2.3 // indirect 14 | github.com/ysmood/goob v0.4.0 // indirect 15 | github.com/ysmood/got v0.40.0 // indirect 16 | github.com/ysmood/leakless v0.9.0 // indirect 17 | golang.org/x/crypto v0.36.0 // indirect 18 | golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb // indirect 19 | golang.org/x/net v0.38.0 // indirect 20 | golang.org/x/sync v0.12.0 // indirect 21 | golang.org/x/sys v0.31.0 // indirect 22 | golang.org/x/text v0.23.0 // indirect 23 | google.golang.org/protobuf v1.31.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /input.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/go-rod/rod/lib/proto" 5 | "github.com/sohaha/zlsgo/zstring" 6 | ) 7 | 8 | func (page *Page) MouseMove(x, y float64, steps ...float64) error { 9 | to := proto.Point{X: x, Y: y} 10 | 11 | if len(steps) == 0 { 12 | return page.page.Mouse.MoveTo(to) 13 | } 14 | 15 | return page.page.Mouse.MoveLinear(to, int(steps[0])) 16 | } 17 | 18 | func (page *Page) MouseMoveToElement(ele *Element, steps ...float64) error { 19 | shape, err := ele.element.Shape() 20 | if err != nil { 21 | return err 22 | } 23 | 24 | box := shape.Box() 25 | 26 | to := proto.Point{X: box.X + float64(zstring.RandInt(0, int(box.Width))), Y: box.Y + float64(zstring.RandInt(0, int(box.Height)))} 27 | 28 | if len(steps) == 0 { 29 | return page.page.Mouse.MoveTo(to) 30 | } 31 | 32 | return page.page.Mouse.MoveLinear(to, int(steps[0])) 33 | } 34 | -------------------------------------------------------------------------------- /assist.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/go-rod/rod/lib/proto" 5 | "github.com/sohaha/zlsgo/zfile" 6 | ) 7 | 8 | // ScreenshotFullPage 截图全屏 9 | func (p *Page) ScreenshotFullPage(file string) error { 10 | b, err := p.ROD().Screenshot(true, nil) 11 | if err != nil { 12 | return err 13 | } 14 | 15 | return zfile.WriteFile(zfile.RealPath(file), b) 16 | } 17 | 18 | // Screenshot 截图 19 | func (p *Page) Screenshot(file string) error { 20 | b, err := p.ROD().Screenshot(false, nil) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return zfile.WriteFile(zfile.RealPath(file), b) 26 | } 27 | 28 | // Screenshot 截图元素 29 | func (ele *Element) Screenshot(file string) error { 30 | b, err := ele.ROD().Screenshot(proto.PageCaptureScreenshotFormatPng, 0) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | return zfile.WriteFile(zfile.RealPath(file), b) 36 | } 37 | -------------------------------------------------------------------------------- /js.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "github.com/ysmood/gson" 7 | ) 8 | 9 | // npx extract-stealth-evasions 10 | 11 | var jsWaitDOMContentLoad = `()=>{const n=this===window;return new Promise((e,t)=>{if(n){if("complete"===document.readyState)return e();window.addEventListener("DOMContentLoaded",e)}else void 0===this.complete||this.complete?e():(this.addEventListener("DOMContentLoaded",e),this.addEventListener("error",t))})}` 12 | 13 | var jsWaitLoad = `()=>{const n=this===window;return new Promise((e,t)=>{if(n){if("complete"===document.readyState)return e();window.addEventListener("load",e)}else void 0===this.complete||this.complete?e():(this.addEventListener("load",e),this.addEventListener("error",t))})}` 14 | 15 | func (page *Page) EvalJS(js string, params ...interface{}) (gson.JSON, error) { 16 | resp, err := page.Timeout().page.Eval(js, params...) 17 | if err != nil { 18 | return gson.JSON{}, err 19 | } 20 | return resp.Value, nil 21 | } 22 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/sohaha/zlsgo/zarray" 8 | "github.com/sohaha/zlsgo/zhttp" 9 | ) 10 | 11 | // Request 发起请求 12 | func (b *Browser) Request(method, url string, v ...interface{}) (*zhttp.Res, error) { 13 | for _, cookie := range b.cookies { 14 | v = append(v, cookie) 15 | } 16 | 17 | resp, err := b.client.Do(method, url, v...) 18 | if err == nil { 19 | cookies := zarray.Values(resp.GetCookie()) 20 | b.SetCookies(cookies) 21 | } 22 | 23 | return resp, err 24 | } 25 | 26 | // Request 发起请求 27 | func (page *Page) Request(method, url string, v ...interface{}) (*zhttp.Res, error) { 28 | return page.browser.Request(method, url, v...) 29 | } 30 | 31 | // SavePageCookie 保存页面 cookie 32 | func (page *Page) SavePageCookie() (cookies []*http.Cookie) { 33 | for _, cookie := range page.page.MustCookies() { 34 | cookies = append(cookies, &http.Cookie{ 35 | Name: cookie.Name, 36 | Value: strings.Trim(cookie.Value, "\""), 37 | Path: cookie.Path, 38 | Domain: cookie.Domain, 39 | }) 40 | } 41 | 42 | page.browser.cookies = page.browser.uniqueCookies(cookies) 43 | 44 | return page.browser.cookies 45 | } 46 | -------------------------------------------------------------------------------- /vaidator.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/go-rod/rod" 7 | "github.com/sohaha/zlsgo/zstring" 8 | ) 9 | 10 | type filterRules struct { 11 | selector string 12 | value string 13 | attr string 14 | isEq bool 15 | } 16 | 17 | func filterElementsRules(f ...string) []filterRules { 18 | var rules []filterRules 19 | for _, v := range f { 20 | r := filterRules{} 21 | s := strings.SplitN(v, "!=", 2) 22 | if len(s) != 2 { 23 | s = strings.SplitN(v, "=", 2) 24 | r.isEq = true 25 | } 26 | if len(s) < 2 { 27 | continue 28 | } 29 | 30 | r.value = strings.TrimSpace(s[1]) 31 | 32 | s = strings.SplitN(s[0], ",", 2) 33 | r.selector = strings.TrimSpace(s[0]) 34 | if len(s) > 1 { 35 | r.attr = strings.TrimSpace(s[len(s)-1]) 36 | } 37 | rules = append(rules, r) 38 | } 39 | 40 | return rules 41 | } 42 | 43 | func filterElements(f ...string) func(e *rod.Element) bool { 44 | var rules []filterRules 45 | if len(f) > 0 { 46 | rules = filterElementsRules(f...) 47 | } 48 | 49 | return func(e *rod.Element) bool { 50 | if len(rules) == 0 { 51 | return true 52 | } 53 | for _, v := range rules { 54 | ele, err := e.Element(v.selector) 55 | if err != nil { 56 | return false 57 | } 58 | 59 | var match bool 60 | if v.attr != "" { 61 | p, err := ele.Property(v.attr) 62 | if err != nil { 63 | return false 64 | } 65 | match = zstring.Match(p.String(), v.value, true) 66 | } else { 67 | match = zstring.Match(ele.MustText(), v.value, true) 68 | } 69 | 70 | return (v.isEq && match) || (!v.isEq && !match) 71 | } 72 | return true 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /action/parse.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sohaha/zlsgo/zjson" 7 | ) 8 | 9 | func parseAction(actionArray []*zjson.Res) (actions Actions) { 10 | if len(actionArray) == 0 { 11 | return nil 12 | } 13 | 14 | for _, v := range actionArray { 15 | actionType := v.Get("action").String() 16 | name := v.Get("name").String() 17 | value := v.Get("value").String() 18 | timeout := v.Get("timeout").Int() 19 | selector := v.Get("selector").String() 20 | next := v.Get("next").Array() 21 | vaidator := v.Get("vaidator") 22 | nextActions := parseAction(next) 23 | action := Action{ 24 | Name: name, 25 | Next: nextActions, 26 | Vaidator: nil, 27 | } 28 | switch actionType { 29 | case "WaitDOMStable": 30 | action.Action = WaitDOMStable(0.5, time.Second*time.Duration(timeout)) 31 | case "InputEnter": 32 | action.Action = InputEnter(selector, value) 33 | case "Elements": 34 | action.Action = Elements(selector, vaidator.Slice().String()...) 35 | case "Screenshot": 36 | action.Action = Screenshot("") 37 | case "ClickNewPage": 38 | action.Action = ClickNewPage(selector) 39 | case "ActivatePage": 40 | action.Action = ActivatePage() 41 | case "ClosePage": 42 | action.Action = ClosePage() 43 | default: 44 | if actionType, ok := actionTypeMap[actionType]; ok { 45 | action = actionType(v) 46 | } 47 | } 48 | if action.Action == nil { 49 | continue 50 | } 51 | actions = append(actions, action) 52 | } 53 | return 54 | } 55 | 56 | var actionTypeMap = map[string]func(v *zjson.Res) Action{} 57 | 58 | func CustomActionType(name string, action func(v *zjson.Res) Action) { 59 | actionTypeMap[name] = action 60 | } 61 | -------------------------------------------------------------------------------- /example/base.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/sohaha/zlsgo/zfile" 5 | "github.com/sohaha/zlsgo/zlog" 6 | "github.com/zlsgo/browser" 7 | ) 8 | 9 | func main() { 10 | b, err := browser.New(func(o *browser.Options) { 11 | // o.DefaultDevice = browser.Device.Clear() 12 | // o.Debug = true 13 | }) 14 | if err != nil { 15 | zlog.Error(err) 16 | return 17 | } 18 | 19 | zfile.Rmdir(zfile.RealPath("tmp")) 20 | 21 | // b.SetPageCookie([]*http.Cookie{ 22 | // { 23 | // Name: "-now", 24 | // Value: "...." + zstring.Rand(8), 25 | // Expires: time.Now().Add(time.Hour * 24), 26 | // Domain: ".73zls.com", 27 | // }, 28 | // // { 29 | // // Name: "now", 30 | // // Value: "test" + zstring.Rand(8), 31 | // // Expires: time.Now().Add(time.Hour * 24), 32 | // // Domain: "", 33 | // // }, 34 | // }) 35 | 36 | zlog.Dump("main") 37 | // b.SavePageCookie() 38 | err = b.Open("http://127.0.0.1:1111", func(p *browser.Page) error { 39 | zlog.Info(p.MustElement("title").Text()) 40 | 41 | // zlog.Error(p.WaitLoad(time.Second * 2)) 42 | zlog.Error(p.Screenshot("tmp/screenshot.png")) 43 | zlog.Error(p.ScreenshotFullPage("tmp/screenshot-full.png")) 44 | zlog.Error(p.MustElement("h2").Screenshot("tmp/screenshot-h2.png")) 45 | // zlog.Dump(p.SetCookie()) 46 | 47 | zlog.Warn(p.ROD().HTML()) 48 | p.SavePageCookie() 49 | 50 | return nil 51 | }) 52 | if err != nil { 53 | zlog.Error(err) 54 | } 55 | 56 | zlog.Debug("dddd") 57 | ss, err := b.GetCookies() 58 | zlog.Debug(err) 59 | for _, v := range ss { 60 | zlog.Debug(v.Name, v.Value, v.Expires) 61 | } 62 | zlog.Debug("dddd") 63 | // zlog.Debug(b.Request("get", "http://127.0.0.1:1111/now")) 64 | } 65 | -------------------------------------------------------------------------------- /element_input.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/go-rod/rod/lib/input" 5 | "github.com/go-rod/rod/lib/proto" 6 | ) 7 | 8 | // FindTextInputElement 查找输入框 9 | func (e *Element) FindTextInputElement(selector ...string) (element *Element, has bool) { 10 | var s string 11 | if len(selector) > 0 && selector[0] != "" { 12 | s = selector[0] 13 | } else { 14 | s = "input" 15 | } 16 | 17 | var elements Elements 18 | elements, has = e.Elements(s) 19 | if !has { 20 | return 21 | } 22 | 23 | for i := range elements { 24 | child := elements[i].ROD() 25 | visible, _ := child.Visible() 26 | if !visible { 27 | continue 28 | } 29 | 30 | typ, err := child.Property("type") 31 | if err != nil { 32 | continue 33 | } 34 | 35 | if typ.String() != "text" && typ.String() != "search" && typ.String() != "textarea" { 36 | continue 37 | } 38 | return &Element{element: child, page: e.page}, true 39 | } 40 | 41 | return nil, false 42 | } 43 | 44 | // InputText 输入文字 45 | func (e *Element) InputText(text string, clear ...bool) error { 46 | if len(clear) > 0 && clear[0] { 47 | _ = e.element.SelectAllText() 48 | } 49 | 50 | return e.element.Input(text) 51 | } 52 | 53 | // InputEnter 输入回车 54 | func (e *Element) InputEnter(presskeys ...input.Key) error { 55 | return e.page.page.KeyActions().Press(presskeys...).Type(input.Enter).Do() 56 | } 57 | 58 | // Click 点击元素 59 | func (e *Element) Click(button ...proto.InputMouseButton) error { 60 | var b proto.InputMouseButton 61 | if len(button) > 0 { 62 | b = button[0] 63 | } else { 64 | b = proto.InputMouseButtonLeft 65 | } 66 | 67 | return e.element.Click(b, 1) 68 | } 69 | 70 | // Focus 聚焦元素 71 | func (e *Element) Focus(button ...proto.InputMouseButton) error { 72 | return e.element.Focus() 73 | } 74 | -------------------------------------------------------------------------------- /element_property.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/go-rod/rod/lib/proto" 5 | "github.com/ysmood/gson" 6 | ) 7 | 8 | // Box 获取元素的边界框 9 | func (e *Element) Box() (*proto.DOMRect, error) { 10 | shape, err := e.element.Shape() 11 | if err != nil { 12 | return nil, err 13 | } 14 | return shape.Box(), nil 15 | } 16 | 17 | // MustBox 获取元素的边界框,如果出错则 panic 18 | func (e *Element) MustBox() *proto.DOMRect { 19 | box, err := e.Box() 20 | if err != nil { 21 | panic(err) 22 | } 23 | return box 24 | } 25 | 26 | // Property 获取元素的属性值 27 | func (e *Element) Property(name string) (gson.JSON, error) { 28 | return e.element.Property(name) 29 | } 30 | 31 | // MustProperty 获取元素的属性值,如果出错则 panic 32 | func (e *Element) MustProperty(name string) gson.JSON { 33 | return e.element.MustProperty(name) 34 | } 35 | 36 | // Text 获取元素的文本内容 37 | func (e *Element) Text() (string, error) { 38 | return e.element.Text() 39 | } 40 | 41 | // MustText 获取元素的文本内容,如果出错则 panic 42 | func (e *Element) MustText() string { 43 | return e.element.MustText() 44 | } 45 | 46 | // HTML 获取元素的 HTML 内容 47 | func (e *Element) HTML() (string, error) { 48 | return e.element.HTML() 49 | } 50 | 51 | // MustHTML 获取元素的 HTML 内容,如果出错则 panic 52 | func (e *Element) MustHTML() string { 53 | return e.element.MustHTML() 54 | } 55 | 56 | // HasClassName 检查元素是否包含指定的类名 57 | func (e *Element) HasClassName(className string) bool { 58 | return e.element.MustEval(`()=>this.classList.contains("` + className + `")`).Bool() 59 | } 60 | 61 | // TagName 获取元素的标签名(小写) 62 | func (e *Element) TagName() (string, error) { 63 | result, err := e.element.Eval(`() => this.tagName.toLowerCase()`) 64 | if err != nil { 65 | return "", err 66 | } 67 | return result.Value.String(), nil 68 | } 69 | 70 | // MustTagName 获取元素的标签名(小写) 71 | func (e *Element) MustTagName() string { 72 | result, err := e.TagName() 73 | if err != nil { 74 | panic(err) 75 | } 76 | return result 77 | } 78 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "math/rand" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "time" 12 | 13 | "github.com/go-rod/rod/lib/launcher" 14 | "github.com/go-rod/rod/lib/proto" 15 | "github.com/sohaha/zlsgo/zfile" 16 | "github.com/sohaha/zlsgo/zutil" 17 | ) 18 | 19 | var cacheDir = "browser" 20 | 21 | func init() { 22 | launcher.DefaultBrowserDir = filepath.Join(map[string]string{ 23 | "windows": os.Getenv("APPDATA"), 24 | "darwin": filepath.Join(os.Getenv("HOME"), ".cache"), 25 | "linux": filepath.Join(os.Getenv("HOME"), ".cache"), 26 | }[runtime.GOOS], cacheDir, "browser") 27 | } 28 | 29 | func isDebian() bool { 30 | if !zutil.IsLinux() { 31 | return false 32 | } 33 | 34 | resp, _ := zfile.ReadFile("/etc/os-release") 35 | if len(resp) == 0 { 36 | return false 37 | } 38 | return bytes.Contains(resp, []byte("debian")) 39 | } 40 | 41 | func copyBody(b io.ReadCloser) (io.ReadCloser, io.ReadCloser, error) { 42 | if b == nil || b == http.NoBody { 43 | return http.NoBody, http.NoBody, nil 44 | } 45 | var buf bytes.Buffer 46 | if _, err := buf.ReadFrom(b); err != nil { 47 | return nil, b, err 48 | } 49 | if err := b.Close(); err != nil { 50 | return nil, b, err 51 | } 52 | return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil 53 | } 54 | 55 | func transformHeaders(h []*proto.FetchHeaderEntry) http.Header { 56 | newHeader := http.Header{} 57 | for _, data := range h { 58 | newHeader.Add(data.Name, data.Value) 59 | } 60 | return newHeader 61 | } 62 | 63 | // RandomSleep randomly pause for a specified time range, unit in milliseconds 64 | func RandomSleep(ms, maxMS int) { 65 | time.Sleep(time.Millisecond * time.Duration(ms+rand.Intn(maxMS))) 66 | } 67 | 68 | // uniqueCookies duplicate removal processing for cookies 69 | // When encountering a Cookie with the same combination of Name+Path+Domain, the latter Cookie will overwrite the former Cookie 70 | func (browser *Browser) uniqueCookies(cookies []*http.Cookie) []*http.Cookie { 71 | cookieMap := make(map[string]*http.Cookie) 72 | 73 | for _, c := range browser.cookies { 74 | key := c.Name + c.Path + c.Domain 75 | cookieMap[key] = c 76 | } 77 | 78 | for _, c := range cookies { 79 | key := c.Name + c.Path + c.Domain 80 | cookieMap[key] = c 81 | } 82 | 83 | nCookies := make([]*http.Cookie, 0, len(cookieMap)) 84 | for i := range cookieMap { 85 | nCookies = append(nCookies, cookieMap[i]) 86 | } 87 | 88 | return nCookies 89 | } 90 | -------------------------------------------------------------------------------- /extension.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "errors" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/go-rod/rod" 9 | "github.com/go-rod/rod/lib/launcher" 10 | "github.com/mediabuyerbot/go-crx3" 11 | "github.com/sohaha/zlsgo/zerror" 12 | "github.com/sohaha/zlsgo/zfile" 13 | "github.com/sohaha/zlsgo/zhttp" 14 | "github.com/sohaha/zlsgo/zstring" 15 | "github.com/sohaha/zlsgo/ztype" 16 | "github.com/sohaha/zlsgo/zvalid" 17 | ) 18 | 19 | func (o *Options) handerExtension() (extensions []string) { 20 | for i := range o.Extensions { 21 | extensionPath := o.Extensions[i] 22 | path, ok, _, err := o.isExtensionURL(extensionPath) 23 | if err != nil { 24 | o.browser.log.Error(err) 25 | continue 26 | } 27 | 28 | if ok { 29 | extensionPath, err = o.downloadExtension(path) 30 | // if isID && err != nil { 31 | // extensionPath, err = o.downloadExtension("https://statics.ilovechrome.com/crx/download/?id=" + o.Extensions[i]) 32 | // } 33 | if err != nil { 34 | o.browser.log.Error(err) 35 | continue 36 | } 37 | } 38 | 39 | if zfile.FileExist(extensionPath) && strings.EqualFold(filepath.Ext(extensionPath), ".crx") { 40 | dir := extensionPath[:len(extensionPath)-4] 41 | if !zfile.DirExist(dir) { 42 | _ = crx3.Extension(extensionPath).Unpack() 43 | } 44 | extensionPath = dir 45 | } 46 | 47 | if zfile.DirExist(extensionPath) { 48 | extensions = append(extensions, extensionPath) 49 | } 50 | } 51 | 52 | return 53 | } 54 | 55 | func (o *Options) downloadExtension(downloadUrl string) (file string, err error) { 56 | file = zfile.TmpPath() + "/zls-extension/" + zstring.Md5(downloadUrl) + ".crx" 57 | if zfile.FileSizeUint(file) > 0 { 58 | return file, nil 59 | } 60 | 61 | resp, err := zhttp.Get(downloadUrl) 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | if resp.StatusCode() != 200 { 67 | return "", errors.New("status code not 200") 68 | } 69 | 70 | err = resp.ToFile(file) 71 | if err != nil { 72 | return "", err 73 | } 74 | 75 | return file, nil 76 | } 77 | 78 | func (o *Options) isExtensionURL(s string) (string, bool, bool, error) { 79 | if !strings.Contains(s, "/") && !strings.Contains(s, ".") { 80 | var product string 81 | err := zerror.TryCatch(func() error { 82 | browser := rod.New().ControlURL(launcher.New().Bin(getBin(o.Bin)).MustLaunch()).MustConnect() 83 | vResult, err := browser.Version() 84 | if err == nil { 85 | product = ztype.ToString(vResult.Product[1]) 86 | } 87 | go browser.Close() 88 | return err 89 | }) 90 | if err != nil { 91 | return "", false, false, err 92 | } 93 | return "https://clients2.google.com/service/update2/crx?response=redirect&prodversion=" + product + "&acceptformat=crx2%2Ccrx3&x=id%3D" + s + "%26uc", true, true, nil 94 | } 95 | 96 | return s, zvalid.Text(s).IsURL().Ok(), false, nil 97 | } 98 | -------------------------------------------------------------------------------- /hijack.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/go-rod/rod" 10 | "github.com/go-rod/rod/lib/proto" 11 | "github.com/sohaha/zlsgo/zhttp" 12 | ) 13 | 14 | type Hijack struct { 15 | *rod.Hijack 16 | client *zhttp.Engine 17 | abort bool 18 | } 19 | 20 | func (h *Hijack) Abort() { 21 | h.abort = true 22 | } 23 | 24 | func newHijacl(h *rod.Hijack, client *zhttp.Engine) *Hijack { 25 | return &Hijack{ 26 | client: client, 27 | Hijack: h, 28 | } 29 | } 30 | 31 | func HijackAllRouter(fn func(b *Hijack) (stop bool)) map[string]HijackProcess { 32 | return map[string]HijackProcess{ 33 | "*": fn, 34 | } 35 | } 36 | 37 | type HijackProcess func(router *Hijack) (stop bool) 38 | 39 | type HijackData struct { 40 | URL *url.URL 41 | Header http.Header 42 | ResponseHeader http.Header 43 | Method string 44 | Request []byte 45 | Response []byte 46 | StatusCode int 47 | } 48 | 49 | func (h *Hijack) HijackRequests(fn func(d *HijackData, err error) bool) bool { 50 | if h.Hijack.Request.Req() != nil && h.Hijack.Request.Req().URL != nil { 51 | data := &HijackData{ 52 | URL: h.Hijack.Request.Req().URL, 53 | Header: h.Hijack.Request.Req().Header, 54 | Method: h.Hijack.Request.Method(), 55 | } 56 | 57 | // reqBytes, _ = httputil.DumpRequest(h.Hijack.Request.Req(), true) 58 | 59 | if h.Hijack.Request.Method() == http.MethodPost { 60 | var save, body io.ReadCloser 61 | save, body, _ = copyBody(h.Hijack.Request.Req().Body) 62 | data.Request, _ = ioutil.ReadAll(save) 63 | h.Hijack.Request.Req().Body = body 64 | } 65 | 66 | err := h.Hijack.LoadResponse(h.client.Client(), true) 67 | if err == nil { 68 | data.StatusCode = h.Hijack.Response.Payload().ResponseCode 69 | data.Response = h.Hijack.Response.Payload().Body 70 | data.ResponseHeader = transformHeaders(h.Hijack.Response.Payload().ResponseHeaders) 71 | } 72 | 73 | return fn(data, err) 74 | } 75 | 76 | return false 77 | } 78 | 79 | func (h *Hijack) IsDispensable() bool { 80 | return h.IsFont() || h.IsImage() || h.IsMedia() || h.IsCSS() || h.IsFont() || h.IsPrefetch() || h.IsFavicon() 81 | } 82 | 83 | func (h *Hijack) IsFavicon() bool { 84 | return h.Hijack.Request.URL().Path == "/favicon.ico" 85 | } 86 | 87 | func (h *Hijack) IsFont() bool { 88 | return h.Hijack.Request.Type() == proto.NetworkResourceTypeFont 89 | } 90 | 91 | func (h *Hijack) IsPrefetch() bool { 92 | return h.Hijack.Request.Type() == proto.NetworkResourceTypePrefetch 93 | } 94 | 95 | func (h *Hijack) IsMedia() bool { 96 | return h.Hijack.Request.Type() == proto.NetworkResourceTypeMedia 97 | } 98 | 99 | func (h *Hijack) IsJS() bool { 100 | return h.Hijack.Request.Type() == proto.NetworkResourceTypeScript 101 | } 102 | 103 | func (h *Hijack) IsCSS() bool { 104 | return h.Hijack.Request.Type() == proto.NetworkResourceTypeStylesheet 105 | } 106 | 107 | func (h *Hijack) IsImage() bool { 108 | return h.Hijack.Request.Type() == proto.NetworkResourceTypeImage 109 | } 110 | -------------------------------------------------------------------------------- /devices.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/go-rod/rod/lib/devices" 7 | "github.com/sohaha/zlsgo/zstring" 8 | "github.com/sohaha/zlsgo/ztype" 9 | ) 10 | 11 | type device struct{} 12 | 13 | var Device = device{} 14 | 15 | func (d device) NoDefaultDevice() devices.Device { 16 | return devices.Device{} 17 | } 18 | 19 | func (d device) Clear() devices.Device { 20 | return devices.Clear 21 | } 22 | 23 | func (d device) IPhoneX() devices.Device { 24 | return devices.IPhoneX 25 | } 26 | 27 | func (d device) IPad() devices.Device { 28 | return devices.IPad 29 | } 30 | 31 | func (d device) IPadMini() devices.Device { 32 | return devices.IPadMini 33 | } 34 | 35 | func (d device) IPadPro() devices.Device { 36 | return devices.IPadPro 37 | } 38 | 39 | func (d device) Pixel2() devices.Device { 40 | return devices.Pixel2 41 | } 42 | 43 | func (d device) Wechat() devices.Device { 44 | return devices.Device{ 45 | Title: "Wechat", 46 | Capabilities: []string{"touch", "mobile"}, 47 | UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 6_1_3 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Mobile/10B329 MicroMessenger/5.0.1", 48 | AcceptLanguage: "zh-CN,zh;q=0.9", 49 | Screen: devices.Screen{ 50 | DevicePixelRatio: 2, 51 | Horizontal: devices.ScreenSize{ 52 | Width: 652, 53 | Height: 338, 54 | }, 55 | Vertical: devices.ScreenSize{ 56 | Width: 338, 57 | Height: 652, 58 | }, 59 | }, 60 | } 61 | } 62 | 63 | type DeviceOptions struct { 64 | Name string 65 | maxMajorVersion int 66 | minMajorVersion int 67 | maxMinorVersion int 68 | minMinorVersion int 69 | maxPatchVersion int 70 | minPatchVersion int 71 | } 72 | 73 | func RandomDevice(opt DeviceOptions, device ...devices.Device) devices.Device { 74 | var d devices.Device 75 | if len(device) > 0 { 76 | d = device[0] 77 | } else { 78 | d = devices.LaptopWithMDPIScreen 79 | } 80 | 81 | nameSplit := strings.Split(d.UserAgent, opt.Name+"/") 82 | if len(nameSplit) < 2 { 83 | return d 84 | } 85 | 86 | versionSplit := strings.Split(strings.Split(nameSplit[1], " ")[0], ".") 87 | originalVersion := strings.Join(versionSplit, ".") 88 | 89 | if opt.maxMajorVersion > 0 { 90 | if opt.maxMajorVersion == opt.minMajorVersion { 91 | versionSplit[0] = ztype.ToString(opt.maxMajorVersion) 92 | } else { 93 | versionSplit[0] = ztype.ToString(zstring.RandInt(opt.maxMajorVersion, opt.minMajorVersion)) 94 | } 95 | } 96 | 97 | if opt.maxMinorVersion > 0 { 98 | if opt.maxMinorVersion == opt.maxMinorVersion { 99 | versionSplit[1] = ztype.ToString(opt.minMinorVersion) 100 | } else { 101 | versionSplit[1] = ztype.ToString(zstring.RandInt(opt.maxMinorVersion, opt.minMinorVersion)) 102 | } 103 | } 104 | 105 | if opt.maxPatchVersion > 0 { 106 | if opt.maxPatchVersion == opt.minPatchVersion { 107 | versionSplit[2] = ztype.ToString(opt.minPatchVersion) 108 | } else { 109 | versionSplit[2] = ztype.ToString(zstring.RandInt(opt.maxPatchVersion, opt.minPatchVersion)) 110 | } 111 | } 112 | 113 | searchValue := opt.Name + "/" + originalVersion 114 | replaceValue := opt.Name + "/" + strings.Join(versionSplit, ".") 115 | 116 | d.UserAgent = strings.ReplaceAll(d.UserAgent, searchValue, replaceValue) 117 | 118 | return d 119 | } 120 | -------------------------------------------------------------------------------- /action/page_action.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/zlsgo/browser" 8 | ) 9 | 10 | type waitDOMStableType struct { 11 | timeout time.Duration 12 | diff float64 13 | } 14 | 15 | var _ ActionType = waitDOMStableType{} 16 | 17 | // WaitDOMStable 等待页面稳定 18 | func WaitDOMStable(diff float64, d ...time.Duration) waitDOMStableType { 19 | o := waitDOMStableType{ 20 | diff: diff, 21 | } 22 | if len(d) > 0 { 23 | o.timeout = d[0] 24 | } 25 | return o 26 | } 27 | 28 | func (o waitDOMStableType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 29 | if o.timeout > 0 { 30 | p.Timeout(o.timeout).WaitDOMStable(o.diff) 31 | } else { 32 | p.WaitDOMStable(o.diff) 33 | } 34 | 35 | return nil, nil 36 | } 37 | 38 | func (o waitDOMStableType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 39 | return nil, errors.New("not support next action") 40 | } 41 | 42 | type ClickNewPageType struct { 43 | selector string 44 | } 45 | 46 | var _ ActionType = ClickNewPageType{} 47 | 48 | // ClickNewPage 点击新页面 49 | func ClickNewPage(selector string) ClickNewPageType { 50 | return ClickNewPageType{ 51 | selector: selector, 52 | } 53 | } 54 | 55 | func (o ClickNewPageType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 56 | element, has := ExtractElement(parentResults...) 57 | if !has { 58 | element, err = p.Element(o.selector) 59 | } else if o.selector != "" { 60 | element, err = element.Element(o.selector) 61 | } 62 | 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | page, err := p.WaitOpen(browser.OpenTypeNewTab, func() error { 68 | return element.Click() 69 | }) 70 | return page, err 71 | } 72 | 73 | func (o ClickNewPageType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 74 | return as.Run(p, value) 75 | } 76 | 77 | type ClosePageType struct{} 78 | 79 | var _ ActionType = ClosePageType{} 80 | 81 | // ClosePage 关闭页面 82 | func ClosePage() ClosePageType { 83 | return ClosePageType{} 84 | } 85 | 86 | func (o ClosePageType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 87 | if len(parentResults) == 0 { 88 | return nil, p.Close() 89 | } 90 | page, has := parentResults[0].Value.(*browser.Page) 91 | if !has { 92 | return nil, errors.New("not found") 93 | } 94 | page.Close() 95 | return nil, nil 96 | } 97 | 98 | func (o ClosePageType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 99 | return nil, errors.New("not support next action") 100 | } 101 | 102 | type ActivatePageType struct{} 103 | 104 | var _ ActionType = ActivatePageType{} 105 | 106 | // ActivatePage 激活页面 107 | func ActivatePage() ActivatePageType { 108 | return ActivatePageType{} 109 | } 110 | 111 | func (o ActivatePageType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 112 | page, has := ExtractPage(parentResults...) 113 | if !has { 114 | _, err := p.ROD().Activate() 115 | return nil, err 116 | } 117 | 118 | _, err = page.ROD().Activate() 119 | if err != nil { 120 | return nil, err 121 | } 122 | return page, nil 123 | } 124 | 125 | func (o ActivatePageType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 126 | return as.Run(p, value) 127 | } 128 | -------------------------------------------------------------------------------- /element.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/go-rod/rod" 8 | "github.com/go-rod/rod/lib/js" 9 | ) 10 | 11 | type Element struct { 12 | element *rod.Element 13 | page *Page 14 | } 15 | 16 | type Elements []*Element 17 | 18 | func (p *Page) Document() (*Element, error) { 19 | jsElement := &js.Function{ 20 | Name: "element", 21 | Definition: `function(e){return document.body}`, 22 | } 23 | e, err := p.ROD().ElementByJS(&rod.EvalOptions{ 24 | ByValue: true, 25 | JSArgs: []interface{}{jsElement}, 26 | JS: fmt.Sprintf(`function (f /* %s */, ...args) { return f.apply(this, args) }`, jsElement.Name), 27 | }) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return &Element{ 33 | element: e, 34 | page: p, 35 | }, nil 36 | } 37 | 38 | func (e *Element) ROD() *rod.Element { 39 | return e.element 40 | } 41 | 42 | func (e *Element) Timeout(d ...time.Duration) *Element { 43 | element := e.element 44 | if e.page.timeout != 0 { 45 | element = element.CancelTimeout() 46 | } 47 | 48 | var timeout time.Duration 49 | if len(d) > 0 { 50 | timeout = d[0] 51 | } else { 52 | timeout = e.page.GetTimeout() 53 | } 54 | 55 | if timeout >= 0 { 56 | element = element.Timeout(timeout) 57 | } 58 | 59 | return &Element{ 60 | element: element, 61 | page: e.page, 62 | } 63 | } 64 | 65 | // HasElement 检查元素是否存在,不会等待元素出现 66 | func (e *Element) Parent() (element *Element, err error) { 67 | ele, err := e.element.Parent() 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | return &Element{ 73 | element: ele, 74 | page: e.page, 75 | }, nil 76 | } 77 | 78 | // Frame 获取元素的 iframe 页面 79 | func (e *Element) Frame() (*Page, error) { 80 | frame, err := e.element.Frame() 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return e.page.FromROD(frame), nil 86 | } 87 | 88 | // HasElement 检查元素是否存在,不会等待元素出现 89 | func (e *Element) HasElement(selector string) (bool, *Element) { 90 | has, ele, _ := e.element.Has(selector) 91 | if !has { 92 | return false, nil 93 | } 94 | 95 | return true, &Element{ 96 | element: ele, 97 | page: e.page, 98 | } 99 | } 100 | 101 | // Element 获取元素,会等待元素出现 102 | func (e *Element) Element(selector string, jsRegex ...string) (element *Element, err error) { 103 | var ( 104 | relm *rod.Element 105 | ) 106 | if len(jsRegex) == 0 { 107 | relm, err = e.element.Element(selector) 108 | } else { 109 | relm, err = e.element.ElementR(selector, jsRegex[0]) 110 | } 111 | 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | return &Element{ 117 | element: relm, 118 | page: e.page, 119 | }, nil 120 | } 121 | 122 | func (e *Element) MustElement(selector string, jsRegex ...string) *Element { 123 | elm, err := e.Element(selector, jsRegex...) 124 | if err != nil { 125 | panic(err) 126 | } 127 | return elm 128 | } 129 | 130 | func (e *Element) Elements(selector string) (elements Elements, has bool) { 131 | _, err := e.element.Element(selector) 132 | if err != nil { 133 | return Elements{}, false 134 | } 135 | 136 | es, _ := e.element.Elements(selector) 137 | has = len(es) > 0 138 | elements = make(Elements, 0, len(es)) 139 | for i := range es { 140 | elements = append(elements, &Element{ 141 | element: es[i], 142 | page: e.page, 143 | }) 144 | } 145 | 146 | return 147 | } 148 | 149 | func (e *Element) MustElements(selector string) Elements { 150 | elements, has := e.Elements(selector) 151 | if !has { 152 | panic(&rod.ElementNotFoundError{}) 153 | } 154 | 155 | return elements 156 | } 157 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= 3 | github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= 4 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 5 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 6 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 7 | github.com/mediabuyerbot/go-crx3 v1.5.1 h1:sX6Nsr6O9yh9lER8JmzzQb6BSTo8saT8bKF9YKM5iN8= 8 | github.com/mediabuyerbot/go-crx3 v1.5.1/go.mod h1:B10Qyzrd2KbHYz2DQU9etINb0CLRI6DjY1qY1ht0iqQ= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/sohaha/zlsgo v1.7.19-0.20250520064226-e335ed952aa2 h1:xzhGF8WXKB3f5CzmIx11tkt3/7BO1XgPtioHBRrtS9g= 11 | github.com/sohaha/zlsgo v1.7.19-0.20250520064226-e335ed952aa2/go.mod h1:MGbu4/f0NIgts6UHKntPoONHMMgFUmJvqMUtQww18L8= 12 | github.com/sohaha/zlsgo v1.7.19-0.20250821063740-f62f4dffb58d h1:iME23x9IaYmfXr4wJckV6VuCGzmNK4oL3rmqDQOjkXM= 13 | github.com/sohaha/zlsgo v1.7.19-0.20250821063740-f62f4dffb58d/go.mod h1:7LViqB5ll09RnlU5V5AW0oBnqFg260/q+B/SGDNNdmQ= 14 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 15 | github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= 16 | github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= 17 | github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= 18 | github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= 19 | github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= 20 | github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= 21 | github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= 22 | github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= 23 | github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= 24 | github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= 25 | github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= 26 | github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= 27 | github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= 28 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 29 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 30 | golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w= 31 | golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= 32 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 33 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 34 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 35 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 36 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 37 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 38 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 39 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 40 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 41 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 42 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 43 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 44 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 45 | -------------------------------------------------------------------------------- /browser.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | "github.com/go-rod/rod" 10 | "github.com/go-rod/rod/lib/launcher" 11 | "github.com/go-rod/rod/lib/proto" 12 | "github.com/sohaha/zlsgo/zfile" 13 | "github.com/sohaha/zlsgo/zhttp" 14 | "github.com/sohaha/zlsgo/zlog" 15 | "github.com/sohaha/zlsgo/zutil" 16 | ) 17 | 18 | type Browser struct { 19 | err error 20 | userAgent *proto.NetworkSetUserAgentOverride 21 | log *zlog.Logger 22 | launcher *launcher.Launcher 23 | Browser *rod.Browser 24 | client *zhttp.Engine 25 | id string 26 | after []func() 27 | before []func() 28 | cookies []*http.Cookie 29 | options Options 30 | isCustomWSEndpoint bool 31 | canUserDir bool 32 | } 33 | 34 | func New(opts ...func(o *Options)) (browser *Browser, err error) { 35 | browser = &Browser{ 36 | client: zhttp.New(), 37 | log: zlog.New(), 38 | } 39 | 40 | browser.options = zutil.Optional(Options{ 41 | autoKill: true, 42 | Headless: true, 43 | // Stealth: true, 44 | browser: browser, 45 | Flags: map[string]string{ 46 | "no-sandbox": "", 47 | "disable-blink-features": "AutomationControlled", 48 | "no-default-browser-check": "", 49 | "no-first-run": "", 50 | // "disable-gpu": "", 51 | // "no-startup-window": "", 52 | "disable-component-update": "", 53 | "window-position": "0,0", 54 | }, 55 | IgnoreCertError: true, 56 | }, opts...) 57 | 58 | browser.Client().EnableCookie(true) 59 | 60 | browser.canUserDir = browser.options.UserMode || browser.options.UserDataDir != "" 61 | 62 | if err := browser.init(); err != nil { 63 | return nil, err 64 | } 65 | 66 | return browser, nil 67 | } 68 | 69 | func (b *Browser) Headless(enable ...bool) (bool, error) { 70 | headless := b.options.Headless 71 | if len(enable) > 0 { 72 | headless = enable[0] 73 | } 74 | 75 | if b.options.Headless == headless { 76 | return headless, nil 77 | } 78 | 79 | if !b.isCustomWSEndpoint { 80 | b.options.WSEndpoint = "" 81 | } 82 | 83 | b.options.Headless = headless 84 | 85 | if b.launcher.PID() != 0 { 86 | p, err := os.FindProcess(b.launcher.PID()) 87 | if err == nil { 88 | _ = p.Kill() 89 | } 90 | } 91 | 92 | return headless, b.init() 93 | } 94 | 95 | func (b *Browser) Kill() { 96 | b.launcher.Kill() 97 | } 98 | 99 | func (b *Browser) NewIncognito() *Browser { 100 | incognito, _ := b.Browser.Incognito() 101 | browser := *b 102 | browser.Browser = incognito 103 | return &browser 104 | } 105 | 106 | func (b *Browser) Close() error { 107 | if b.Browser == nil { 108 | return nil 109 | } 110 | return b.Browser.Close() 111 | } 112 | 113 | func (b *Browser) Cleanup() { 114 | if !b.canUserDir && b.options.UserDataDir != "" { 115 | _ = zfile.Rmdir(b.options.UserDataDir) 116 | } 117 | } 118 | 119 | func (b *Browser) Release() { 120 | b.Cleanup() 121 | } 122 | 123 | // SetCookie set global cookies 124 | func (b *Browser) SetCookies(cookies []*http.Cookie) error { 125 | if cookies == nil { 126 | b.cookies = make([]*http.Cookie, 0, 0) 127 | _ = b.Browser.SetCookies(nil) 128 | return nil 129 | } 130 | 131 | b.cookies = b.uniqueCookies(cookies) 132 | c, err := b.cookiesToProto(cookies) 133 | if err != nil { 134 | return errors.New("failed to set cookie: " + err.Error()) 135 | } 136 | 137 | b.Browser.SetCookies(c) 138 | return nil 139 | } 140 | 141 | // GetCookie get global cookies 142 | func (b *Browser) GetCookies() ([]*http.Cookie, error) { 143 | protoCookies, err := b.Browser.GetCookies() 144 | if err != nil { 145 | return []*http.Cookie{}, err 146 | } 147 | 148 | cookies := make([]*http.Cookie, 0, len(protoCookies)) 149 | for i := range protoCookies { 150 | value := protoCookies[i].Value 151 | if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") { 152 | value = value[1 : len(value)-1] 153 | } 154 | cookie := http.Cookie{ 155 | Name: protoCookies[i].Name, 156 | Value: value, 157 | Path: protoCookies[i].Path, 158 | Domain: protoCookies[i].Domain, 159 | Secure: protoCookies[i].Secure, 160 | HttpOnly: protoCookies[i].HTTPOnly, 161 | } 162 | if protoCookies[i].Expires > 0 { 163 | cookie.Expires = protoCookies[i].Expires.Time() 164 | } 165 | cookies = append(cookies, &cookie) 166 | } 167 | 168 | return cookies, nil 169 | } 170 | 171 | func (b *Browser) cookiesToProto(cookies []*http.Cookie) ([]*proto.NetworkCookieParam, error) { 172 | protoCookies := make([]*proto.NetworkCookieParam, 0, len(cookies)) 173 | for i := range cookies { 174 | if cookies[i].Domain == "" { 175 | return nil, errors.New("domain is required for cookie configuration") 176 | } 177 | if cookies[i].Name == "" { 178 | return nil, errors.New("name is required for cookie configuration") 179 | } 180 | 181 | protoCookies = append(protoCookies, &proto.NetworkCookieParam{ 182 | Name: cookies[i].Name, 183 | Value: cookies[i].Value, 184 | Expires: proto.TimeSinceEpoch(cookies[i].Expires.Unix()), 185 | Path: cookies[i].Path, 186 | Domain: cookies[i].Domain, 187 | Secure: cookies[i].Secure, 188 | HTTPOnly: cookies[i].HttpOnly, 189 | }) 190 | } 191 | 192 | return protoCookies, nil 193 | } 194 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/go-rod/rod" 11 | "github.com/go-rod/rod/lib/devices" 12 | "github.com/go-rod/rod/lib/launcher" 13 | "github.com/go-rod/rod/lib/launcher/flags" 14 | "github.com/go-rod/rod/lib/proto" 15 | "github.com/sohaha/zlsgo/zcli" 16 | "github.com/sohaha/zlsgo/zerror" 17 | "github.com/sohaha/zlsgo/zfile" 18 | "github.com/sohaha/zlsgo/zstring" 19 | "github.com/sohaha/zlsgo/ztype" 20 | "github.com/sohaha/zlsgo/zutil" 21 | ) 22 | 23 | type Options struct { 24 | browser *Browser 25 | Hijack HijackProcess 26 | Flags map[string]string 27 | Bin string 28 | WSEndpoint string 29 | UserAgent string 30 | UserDataDir string 31 | AcceptLanguage string 32 | ProxyUrl string 33 | DefaultDevice devices.Device 34 | Envs []string 35 | Scripts []string 36 | Extensions []string 37 | SlowMotion time.Duration 38 | Timeout time.Duration 39 | Headless bool 40 | Incognito bool 41 | UserMode bool 42 | Devtools bool 43 | IgnoreCertError bool 44 | autoKill bool 45 | Stealth bool 46 | Leakless bool 47 | Debug bool 48 | } 49 | 50 | func (b *Browser) init() (err error) { 51 | if b == nil { 52 | return errors.New("browser is nil") 53 | } 54 | 55 | if b.options.UserMode { 56 | b.launcher = launcher.NewUserMode() 57 | b.options.Headless = false 58 | } else { 59 | b.launcher = launcher.New() 60 | } 61 | 62 | for _, v := range []func(b *Browser){ 63 | setBin, 64 | setDebug, 65 | setLeakless, 66 | setDefaultDevice, 67 | setUserDataDir, 68 | setEnv, 69 | setFlags, 70 | setExtensions, 71 | } { 72 | v(b) 73 | } 74 | b.launcher.Headless(b.options.Headless) 75 | 76 | if b.options.ProxyUrl != "" { 77 | _ = b.client.SetProxyUrl(b.options.ProxyUrl) 78 | } 79 | 80 | if b.options.UserAgent != "" || b.options.AcceptLanguage != "" { 81 | ua := &proto.NetworkSetUserAgentOverride{ 82 | AcceptLanguage: "en-US,en;q=0.9", 83 | } 84 | if b.options.AcceptLanguage != "" { 85 | ua.AcceptLanguage = b.options.AcceptLanguage 86 | } 87 | if b.options.UserAgent != "" { 88 | ua.UserAgent = b.options.UserAgent 89 | } 90 | b.userAgent = ua 91 | } 92 | 93 | if b.options.WSEndpoint == "" { 94 | b.options.WSEndpoint, err = b.launcher.Logger(ioutil.Discard).Launch() 95 | if err != nil { 96 | if strings.Contains(err.Error(), "Failed to launch the browser") { 97 | errMsg := "Failed to launch the browser" 98 | if zutil.IsLinux() { 99 | if isDebian() { 100 | errMsg += `: sudo apt-get install --no-install-recommends -y libnss3 libxss1 libasound2t64 libxtst6 libgtk-3-0 libgbm1 ca-certificates fonts-liberation fonts-noto-color-emoji fonts-noto-cjk` 101 | } else { 102 | errMsg += ": https://pptr.dev/troubleshooting#chrome-doesnt-launch-on-linux" 103 | } 104 | } 105 | return errors.New(errMsg) 106 | } 107 | return err 108 | } 109 | } else { 110 | b.isCustomWSEndpoint = true 111 | } 112 | b.id = ztype.ToString(zstring.UUID()) 113 | b.Browser = rod.New().ControlURL(b.options.WSEndpoint) 114 | 115 | for _, v := range b.before { 116 | v() 117 | } 118 | 119 | if err = b.Browser.Connect(); err != nil { 120 | return err 121 | } 122 | 123 | if b.options.Incognito { 124 | b.Browser, err = b.Browser.Incognito() 125 | if err != nil { 126 | return err 127 | } 128 | } 129 | 130 | if b.options.IgnoreCertError { 131 | _ = b.Browser.IgnoreCertErrors(true) 132 | } 133 | 134 | for _, v := range b.after { 135 | v() 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func setEnv(b *Browser) { 142 | b.launcher.Env(b.options.Envs...) 143 | } 144 | 145 | func setExtensions(b *Browser) { 146 | extensions := strings.Join(b.options.handerExtension(), ",") 147 | if extensions == "" { 148 | return 149 | } 150 | 151 | b.launcher.Set("load-extension", extensions) 152 | } 153 | 154 | func setFlags(b *Browser) { 155 | for n, v := range b.options.Flags { 156 | _ = zerror.TryCatch(func() error { 157 | if v == "" { 158 | b.launcher = b.launcher.Set(flags.Flag(n)) 159 | } else { 160 | b.launcher = b.launcher.Set(flags.Flag(n), v) 161 | } 162 | return nil 163 | }) 164 | } 165 | if b.options.ProxyUrl != "" { 166 | b.launcher = b.launcher.Set(flags.ProxyServer, b.options.ProxyUrl) 167 | } 168 | } 169 | 170 | func setLeakless(b *Browser) { 171 | if b.id != "" { 172 | return 173 | } 174 | 175 | b.launcher.Leakless(b.options.Leakless) 176 | 177 | go func() { 178 | <-zcli.SingleKillSignal() 179 | 180 | if b.launcher.PID() != 0 { 181 | p, err := os.FindProcess(b.launcher.PID()) 182 | if err == nil { 183 | _ = p.Kill() 184 | } 185 | } 186 | 187 | _ = b.Close() 188 | b.Cleanup() 189 | 190 | os.Exit(0) 191 | }() 192 | } 193 | 194 | func setDefaultDevice(b *Browser) { 195 | b.after = append(b.after, func() { 196 | if b.options.DefaultDevice.Title == "" { 197 | b.Browser.NoDefaultDevice() 198 | } else { 199 | b.Browser.DefaultDevice(b.options.DefaultDevice) 200 | } 201 | 202 | if v, err := b.Browser.Version(); err == nil { 203 | if b.userAgent == nil { 204 | userAgent := strings.Replace(v.UserAgent, "Headless", "", -1) 205 | b.userAgent = &proto.NetworkSetUserAgentOverride{UserAgent: userAgent} 206 | } 207 | 208 | b.client.SetUserAgent(func() string { 209 | if b.userAgent == nil { 210 | return strings.Replace(v.UserAgent, "Headless", "", -1) 211 | } 212 | 213 | return b.userAgent.UserAgent 214 | }) 215 | } 216 | }) 217 | } 218 | 219 | // setBin 优先使用本地浏览器 220 | func setBin(b *Browser) { 221 | b.launcher.Bin(getBin(b.options.Bin)) 222 | } 223 | 224 | func getBin(path string) string { 225 | if path == "" { 226 | if p, exists := launcher.LookPath(); exists { 227 | path = p 228 | } 229 | } 230 | if !zfile.FileExist(path) { 231 | browser := launcher.NewBrowser() 232 | browser.Logger = newLogger() 233 | bin, err := browser.Get() 234 | if err == nil { 235 | return bin 236 | } 237 | 238 | } 239 | return path 240 | } 241 | 242 | // setDebug 调试模式 243 | func setDebug(b *Browser) { 244 | debug := b.options.Debug 245 | if b.options.Devtools { 246 | debug = true 247 | b.launcher.Devtools(true) 248 | } 249 | 250 | if debug { 251 | b.after = append(b.after, func() { 252 | b.Browser.Trace(true) 253 | b.Browser.SlowMotion(b.options.SlowMotion) 254 | b.Browser.Logger(newLogger()) 255 | }) 256 | } 257 | } 258 | 259 | // setUserDataDir 用户数据保存目录 260 | func setUserDataDir(b *Browser) { 261 | if b.options.UserMode { 262 | return 263 | } 264 | 265 | if b.options.UserDataDir == "" { 266 | b.options.UserDataDir = zfile.TmpPath() + "/browser/" + zstring.Rand(8) 267 | } 268 | 269 | b.launcher.UserDataDir(zfile.RealPath(b.options.UserDataDir)) 270 | } 271 | -------------------------------------------------------------------------------- /action/action.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/sohaha/zlsgo/zarray" 9 | "github.com/zlsgo/browser" 10 | ) 11 | 12 | type TimeoutType struct { 13 | timeout time.Duration 14 | } 15 | 16 | // TimeoutAction 设置后续动作的超时时间 17 | func TimeouAction(timeout time.Duration) TimeoutType { 18 | return TimeoutType{ 19 | timeout: timeout, 20 | } 21 | } 22 | 23 | func (o TimeoutType) Do(p *browser.Page, as Actions, panicErr bool) (s any, err error) { 24 | *p = *p.Timeout(o.timeout) 25 | return nil, nil 26 | } 27 | 28 | type ClickType struct { 29 | selector string 30 | } 31 | 32 | var _ ActionType = ClickType{} 33 | 34 | // Click 点击元素 35 | func Click(selector string) ClickType { 36 | return ClickType{ 37 | selector: selector, 38 | } 39 | } 40 | 41 | func (o ClickType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 42 | element, has := ExtractElement(parentResults...) 43 | if has { 44 | if o.selector != "" { 45 | element, err = element.Element(o.selector) 46 | } 47 | if err != nil { 48 | return nil, err 49 | } 50 | return nil, element.Click() 51 | } 52 | 53 | return nil, p.MustElement(o.selector).Click() 54 | } 55 | 56 | func (o ClickType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 57 | return as.Run(p, value) 58 | } 59 | 60 | type RaceElementType struct { 61 | SuccessSelectors []string 62 | FailedSelectors []string 63 | maxRetry int 64 | timeout time.Duration 65 | } 66 | 67 | // RaceElement 竞争元素,结果为第一个成功元素 68 | func RaceElement(successSelectors, failedSelectors []string, maxRetry int, timeout ...time.Duration) RaceElementType { 69 | o := RaceElementType{ 70 | SuccessSelectors: successSelectors, 71 | FailedSelectors: failedSelectors, 72 | maxRetry: maxRetry, 73 | } 74 | if len(timeout) > 0 { 75 | o.timeout = timeout[0] 76 | } 77 | return o 78 | } 79 | 80 | func (o RaceElementType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 81 | maxRetry := o.maxRetry 82 | var run func() (ele *browser.Element, err error) 83 | run = func() (ele *browser.Element, err error) { 84 | page := p 85 | fns := make(map[string]browser.RaceElementFunc, len(o.SuccessSelectors)+len(o.FailedSelectors)) 86 | if o.timeout > 0 { 87 | page = p.Timeout(o.timeout + (time.Duration(maxRetry-o.maxRetry) * time.Second)) 88 | } 89 | all := append(o.SuccessSelectors, o.FailedSelectors...) 90 | for i := range all { 91 | v := all[i] 92 | if v == "" { 93 | continue 94 | } 95 | if _, ok := fns[v]; ok { 96 | return nil, errors.New("selector must be unique: " + v) 97 | } 98 | 99 | fns[v] = browser.RaceElementFunc{ 100 | Element: func(p *browser.Page) (bool, *browser.Element) { 101 | return page.HasElement(v) 102 | }, 103 | Handle: func(element *browser.Element) (retry bool, err error) { 104 | if zarray.Contains(o.SuccessSelectors, v) { 105 | return false, nil 106 | } 107 | o.maxRetry-- 108 | if o.maxRetry > 0 { 109 | return true, nil 110 | } 111 | return false, errors.New("failed to find element: " + v) 112 | }, 113 | } 114 | } 115 | 116 | _, ele, err = page.RaceElement(fns) 117 | if err == context.DeadlineExceeded { 118 | if o.maxRetry > 0 { 119 | o.maxRetry-- 120 | err = p.Reload() 121 | if err != nil { 122 | return nil, err 123 | } 124 | return run() 125 | } 126 | return nil, err 127 | } 128 | return 129 | } 130 | 131 | return run() 132 | } 133 | 134 | func (o RaceElementType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 135 | return as.Run(p, value) 136 | } 137 | 138 | type IfElementType struct { 139 | selector string 140 | } 141 | 142 | func IfElement(selector string) IfElementType { 143 | return IfElementType{ 144 | selector: selector, 145 | } 146 | } 147 | 148 | func (o IfElementType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 149 | element, has := ExtractElement(parentResults...) 150 | if !has { 151 | return nil, nil 152 | } 153 | ele, err := element.Parent() 154 | if err != nil { 155 | return nil, nil 156 | } 157 | 158 | has, ele = ele.HasElement(o.selector) 159 | if !has { 160 | return nil, nil 161 | } 162 | 163 | return ele, nil 164 | } 165 | 166 | func (o IfElementType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 167 | _, has := ExtractElement(value) 168 | if !has { 169 | return nil, nil 170 | } 171 | 172 | return as.Run(p, value) 173 | } 174 | 175 | type ElementsType struct { 176 | selector string 177 | filter []string 178 | } 179 | 180 | // Elements 获取元素, 结果为元素列表 181 | func Elements(selector string, filter ...string) ElementsType { 182 | o := ElementsType{selector: selector} 183 | if len(filter) > 0 { 184 | o.filter = filter 185 | } 186 | return o 187 | } 188 | 189 | func (o ElementsType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 190 | elements, has := p.Elements(o.selector, o.filter...) 191 | if !has { 192 | return nil, errors.New("not found") 193 | } 194 | 195 | return elements, nil 196 | } 197 | 198 | func (o ElementsType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 199 | return as.Run(p, value) 200 | } 201 | 202 | type ToFrameType struct { 203 | selector string 204 | } 205 | 206 | func ToFrame(selector ...string) ToFrameType { 207 | o := ToFrameType{} 208 | if len(selector) > 0 { 209 | o.selector = selector[0] 210 | } 211 | return o 212 | } 213 | 214 | func (o ToFrameType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 215 | element, has := ExtractElement(parentResults...) 216 | if !has { 217 | return nil, errors.New("not found") 218 | } 219 | 220 | if o.selector != "" { 221 | has, element = element.HasElement(o.selector) 222 | if !has { 223 | return nil, errors.New("not found") 224 | } 225 | } 226 | 227 | page, err := element.ROD().Frame() 228 | if err != nil { 229 | return nil, err 230 | } 231 | 232 | return p.FromROD(page).Document() 233 | } 234 | 235 | func (o ToFrameType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 236 | return as.Run(p, value) 237 | } 238 | 239 | type CustomType struct { 240 | fn func(p *browser.Page, parentResults ...ActionResult) (s any, err error) 241 | } 242 | 243 | var _ ActionType = CustomType{} 244 | 245 | func Custom(fn func(p *browser.Page, parentResults ...ActionResult) (s any, err error)) CustomType { 246 | return CustomType{ 247 | fn: fn, 248 | } 249 | } 250 | 251 | func (o CustomType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 252 | return o.fn(p.Timeout(p.GetTimeout()), parentResults...) 253 | } 254 | 255 | func (o CustomType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 256 | return as.Run(p, value) 257 | } 258 | -------------------------------------------------------------------------------- /action/auto.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/go-rod/rod/lib/proto" 9 | "github.com/sohaha/zlsgo/zarray" 10 | "github.com/sohaha/zlsgo/zerror" 11 | "github.com/sohaha/zlsgo/zfile" 12 | "github.com/sohaha/zlsgo/zjson" 13 | "github.com/sohaha/zlsgo/zlog" 14 | "github.com/sohaha/zlsgo/zreflect" 15 | "github.com/sohaha/zlsgo/zstring" 16 | "github.com/sohaha/zlsgo/ztype" 17 | "github.com/sohaha/zlsgo/zutil" 18 | "github.com/zlsgo/browser" 19 | ) 20 | 21 | var Debug = zutil.NewBool(false) 22 | 23 | type AutoResult []ActionResult 24 | 25 | func (a *AutoResult) String() string { 26 | j, err := zjson.Marshal(a) 27 | if err != nil { 28 | return "[]" 29 | } 30 | return zstring.Bytes2String(j) 31 | } 32 | 33 | type ActionResult struct { 34 | Value any `json:"value,omitempty"` 35 | Name string `json:"name,omitempty"` 36 | key string 37 | Err string `json:"error,omitempty"` 38 | Child []ActionResult `json:"child,omitempty"` 39 | } 40 | 41 | type ActionType interface { 42 | Do(p *browser.Page, parentResults ...ActionResult) (any, error) 43 | Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) 44 | } 45 | type Actions []Action 46 | 47 | // Run 执行 action 48 | func (as Actions) Run(page *browser.Page, parentResults ...ActionResult) (data []ActionResult, err error) { 49 | // p := page.Timeout(page.GetTimeout()) 50 | p := page 51 | as = zarray.Filter(as, func(_ int, v Action) bool { 52 | return v.Action != nil 53 | }) 54 | data = make([]ActionResult, 0, len(as)) 55 | keys := zarray.Map(as, func(_ int, v Action) string { 56 | return v.Name 57 | }) 58 | if len(keys) != len(zarray.Unique(keys)) { 59 | return nil, errors.New("action name is not unique") 60 | } 61 | 62 | for _, action := range as { 63 | res := ActionResult{Name: action.Name, key: action.Name} 64 | 65 | var parent ActionResult 66 | if len(parentResults) > 0 { 67 | parent = parentResults[0] 68 | res.key = parent.key + "_" + res.key 69 | } else { 70 | parent = res 71 | } 72 | 73 | if Debug.Load() { 74 | zlog.Tips("执行", res.key) 75 | } 76 | 77 | fn := func() { 78 | value, err := action.Action.Do(p, parent) 79 | res.Value = value 80 | if err != nil { 81 | res.Err = err.Error() 82 | } 83 | if action.Vaidator != nil { 84 | err = action.Vaidator(p, res) 85 | if err != nil { 86 | res.Err = err.Error() 87 | } else { 88 | res.Err = "" 89 | } 90 | } 91 | 92 | if len(action.Next) > 0 && res.Err == "" { 93 | val := zreflect.ValueOf(res.Value) 94 | if val.Kind() == reflect.Slice { 95 | for i := 0; i < val.Len(); i++ { 96 | nres := ActionResult{ 97 | Value: val.Index(i).Interface(), 98 | Name: action.Name, 99 | key: res.key + "_" + ztype.ToString(i+1), 100 | } 101 | child, err := action.Action.Next(p, action.Next, nres) 102 | if err != nil { 103 | res.Err = err.Error() 104 | break 105 | } 106 | res.Child = append(res.Child, child...) 107 | } 108 | } else { 109 | res.Child, err = action.Action.Next(p, action.Next, res) 110 | if err != nil { 111 | res.Err = err.Error() 112 | } else { 113 | res.Err = "" 114 | } 115 | } 116 | } 117 | data = append(data, res) 118 | } 119 | fn() 120 | if res.Err != "" { 121 | break 122 | } 123 | } 124 | return 125 | } 126 | 127 | type textType string 128 | 129 | var _ ActionType = textType("") 130 | 131 | // TextAction 获取元素文本 132 | func TextAction(selector string) textType { 133 | return textType(selector) 134 | } 135 | 136 | func (o textType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 137 | err = zerror.TryCatch(func() error { 138 | s = p.MustElement(string(o)).MustText() 139 | return nil 140 | }) 141 | return 142 | } 143 | 144 | func (o textType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 145 | return nil, errors.New("not support next action") 146 | } 147 | 148 | type InputEnterType struct { 149 | text string 150 | selector string 151 | } 152 | 153 | // InputEnter 输入文本 154 | func InputEnter(selector, text string) InputEnterType { 155 | return InputEnterType{ 156 | text: text, 157 | selector: selector, 158 | } 159 | } 160 | 161 | func (o InputEnterType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 162 | err = zerror.TryCatch(func() error { 163 | e, has := p.MustElement("body").Timeout().FindTextInputElement(o.selector) 164 | if !has { 165 | return errors.New("input not found") 166 | } 167 | e.InputText(o.text, true) 168 | return e.InputEnter() 169 | }) 170 | s = o.text 171 | return 172 | } 173 | 174 | func (o InputEnterType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 175 | return nil, errors.New("not support next action") 176 | } 177 | 178 | type ScreenshoType struct { 179 | selector string 180 | file string 181 | } 182 | 183 | // Screenshot 截图 184 | func Screenshot(file string, selector ...string) ScreenshoType { 185 | s := ScreenshoType{file: file} 186 | if len(selector) > 0 { 187 | s.selector = selector[0] 188 | } 189 | 190 | return s 191 | } 192 | 193 | func (o ScreenshoType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 194 | file := o.file 195 | if file == "" && len(parentResults) > 0 { 196 | file = parentResults[0].key + ".png" 197 | } 198 | if file == "" { 199 | return nil, errors.New("filename is required") 200 | } 201 | file = zfile.RealPath(file) 202 | 203 | element, has := ExtractElement(parentResults...) 204 | if has { 205 | if o.selector != "" { 206 | element, err = element.Element(o.selector) 207 | if err != nil { 208 | return nil, err 209 | } 210 | } 211 | 212 | bin, err := element.ROD().Timeout(time.Second*3).Screenshot(proto.PageCaptureScreenshotFormatPng, 0) 213 | if err != nil { 214 | return nil, errors.New("screenshot failed") 215 | } 216 | 217 | return file, zfile.WriteFile(file, bin) 218 | } 219 | 220 | page, has := ExtractPage(parentResults...) 221 | if has { 222 | p = page 223 | } 224 | if o.selector != "" { 225 | p.MustElement(o.selector).ROD().MustScreenshot(file) 226 | return 227 | } else { 228 | p.ROD().MustScreenshotFullPage(file) 229 | } 230 | s = zfile.SafePath(file) 231 | return 232 | } 233 | 234 | func (o ScreenshoType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 235 | return nil, errors.New("not support next action") 236 | } 237 | 238 | type ScreenshoFullType struct { 239 | file string 240 | } 241 | 242 | var _ ActionType = ScreenshoFullType{} 243 | 244 | // ScreenshotFullPage 截图整个页面 245 | func ScreenshotFullPage(file string) ScreenshoFullType { 246 | return ScreenshoFullType{file: file} 247 | } 248 | 249 | func (o ScreenshoFullType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 250 | _ = p.WaitDOMStable(0) 251 | file := zfile.RealPath(o.file) 252 | bin, err := p.ROD().Screenshot(true, nil) 253 | if err != nil { 254 | return nil, errors.New("screenshot failed") 255 | } 256 | 257 | return zfile.SafePath(file), zfile.WriteFile(file, bin) 258 | } 259 | 260 | func (o ScreenshoFullType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 261 | return nil, errors.New("not support next action") 262 | } 263 | 264 | type SleepType struct { 265 | timeout time.Duration 266 | } 267 | 268 | // Sleep 等待 269 | func Sleep(timeout time.Duration) SleepType { 270 | return SleepType{timeout: timeout} 271 | } 272 | 273 | func (o SleepType) Do(p *browser.Page, parentResults ...ActionResult) (s any, err error) { 274 | time.Sleep(o.timeout) 275 | return 276 | } 277 | 278 | func (o SleepType) Next(p *browser.Page, as Actions, value ActionResult) ([]ActionResult, error) { 279 | return nil, errors.New("not support next action") 280 | } 281 | 282 | type Action struct { 283 | Action ActionType 284 | Vaidator func(p *browser.Page, value ActionResult) error 285 | Name string 286 | Next Actions 287 | } 288 | 289 | type Auto struct { 290 | browser *browser.Browser 291 | url string 292 | actions Actions 293 | timeout time.Duration 294 | debug bool 295 | } 296 | 297 | // NewAuto 创建自动执行器 298 | func NewAuto(b *browser.Browser, url string, actions []Action) *Auto { 299 | return &Auto{ 300 | browser: b, 301 | url: url, 302 | actions: actions, 303 | } 304 | } 305 | 306 | // Start 开始执行 307 | func (a *Auto) Start(opt ...func(o *browser.PageOptions)) (data AutoResult, err error) { 308 | if a.url == "" { 309 | return nil, errors.New("url is required") 310 | } 311 | 312 | data = make([]ActionResult, 0, len(a.actions)) 313 | err = a.browser.Open(a.url, func(p *browser.Page) error { 314 | data, err = a.actions.Run(p) 315 | return err 316 | }, func(o *browser.PageOptions) { 317 | if a.timeout > 0 { 318 | o.Timeout = a.timeout 319 | } 320 | if len(opt) > 0 { 321 | opt[0](o) 322 | } 323 | }) 324 | 325 | return 326 | } 327 | 328 | // Close 关闭浏览器 329 | func (a *Auto) Close() error { 330 | return a.browser.Close() 331 | } 332 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Browser 浏览器自动化库 2 | 3 | 🚀 强大且易用的 Go 浏览器自动化库,基于 rod 构建,提供流畅的 API 和链式调用支持。 4 | 5 | ## ✨ 特性 6 | 7 | - 🏗️ **Builder 模式配置** - 简洁直观的浏览器配置 8 | - 🔗 **流式链式调用** - 优雅的操作链,减少样板代码 9 | - 📋 **智能错误处理** - 详细的错误信息和重试机制 10 | - 🎯 **预设配置** - 开发、生产、测试等环境预设 11 | - 📱 **设备模拟** - 内置移动设备、平板等模拟 12 | - 🕵️ **隐形模式** - 反检测浏览器指纹 13 | - ⚡ **高性能** - 基于成熟的 rod 库构建 14 | 15 | ## 🚀 快速开始 16 | 17 | ### 安装 18 | 19 | ```bash 20 | go get github.com/sohaha/zlsgo/browser 21 | ``` 22 | 23 | ### 基础使用 24 | 25 | ```go 26 | package main 27 | 28 | import ( 29 | "fmt" 30 | "github.com/sohaha/zlsgo/browser" 31 | ) 32 | 33 | func main() { 34 | // 使用新的 Builder API 35 | browserInstance := browser.NewBrowser(). 36 | WithHeadless(false). 37 | WithTimeout(30 * time.Second). 38 | MustBuild() 39 | defer browserInstance.Close() 40 | 41 | // 流式页面操作 42 | err := browserInstance.Open("https://example.com", func(page *browser.Page) error { 43 | return page.Chain(). 44 | WaitForLoad(). 45 | ClickOn("#login-button"). 46 | FillForm(map[string]string{ 47 | "#username": "user@example.com", 48 | "#password": "password123", 49 | }). 50 | SubmitForm(). 51 | WaitForText("登录成功"). 52 | Error() 53 | }) 54 | 55 | if err != nil { 56 | fmt.Printf("操作失败: %v\n", err) 57 | } 58 | } 59 | ``` 60 | 61 | ## 📖 API 文档 62 | 63 | ### 🏗️ Builder 模式创建浏览器 64 | 65 | #### 基础配置 66 | 67 | ```go 68 | // 创建浏览器构建器 69 | browser := browser.NewBrowser(). 70 | WithHeadless(true). // 无头模式 71 | WithUserAgent("CustomBot/1.0"). // 自定义 User-Agent 72 | WithTimeout(30 * time.Second). // 超时设置 73 | WithProxy("http://proxy:8080"). // 代理设置 74 | MustBuild() 75 | ``` 76 | 77 | #### 预设配置 78 | 79 | ```go 80 | // 开发环境 - 可见界面,开启调试 81 | devBrowser := browser.NewBrowser(). 82 | Preset(browser.PresetDevelopment). 83 | MustBuild() 84 | 85 | // 生产环境 - 无头,隐形,内存优化 86 | prodBrowser := browser.NewBrowser(). 87 | Preset(browser.PresetProduction). 88 | MustBuild() 89 | 90 | // 测试环境 - 快速,隐身 91 | testBrowser := browser.NewBrowser(). 92 | Preset(browser.PresetTesting). 93 | MustBuild() 94 | 95 | // 隐形模式 - 反检测 96 | stealthBrowser := browser.NewBrowser(). 97 | Preset(browser.PresetStealth). 98 | MustBuild() 99 | ``` 100 | 101 | #### 设备模拟 102 | 103 | ```go 104 | // 移动设备模拟 105 | mobileBrowser := browser.NewBrowser(). 106 | WithMobileDevice(). 107 | MustBuild() 108 | 109 | // 平板设备模拟 110 | tabletBrowser := browser.NewBrowser(). 111 | WithTabletDevice(). 112 | MustBuild() 113 | 114 | // 自定义设备 115 | customBrowser := browser.NewBrowser(). 116 | WithDevice(devices.IPhoneX). 117 | MustBuild() 118 | ``` 119 | 120 | ### 🔗 流式页面操作 121 | 122 | #### 基础页面操作 123 | 124 | ```go 125 | err := page.Chain(). 126 | NavigateTo("https://example.com"). // 导航到页面 127 | WaitForLoad(). // 等待加载完成 128 | WaitForElement("#content"). // 等待元素出现 129 | ScrollTo("#footer"). // 滚动到元素 130 | Error() // 获取错误 131 | ``` 132 | 133 | #### 表单操作 134 | 135 | ```go 136 | // 单个输入 137 | err := page.Chain(). 138 | TypeInto("#search", "Go语言"). 139 | PressEnter(). 140 | Error() 141 | 142 | // 批量表单填充 143 | err := page.Chain(). 144 | FillForm(map[string]string{ 145 | "#name": "张三", 146 | "#email": "zhangsan@example.com", 147 | "#message": "这是测试消息", 148 | }). 149 | SubmitForm(). 150 | Error() 151 | ``` 152 | 153 | #### 元素交互 154 | 155 | ```go 156 | // 链式元素操作 157 | element := page.Chain(). 158 | FindElement("#button"). 159 | Hover(). // 悬停 160 | Click(). // 点击 161 | WaitVisible(). // 等待可见 162 | MustComplete() // 必须成功 163 | ``` 164 | 165 | #### 等待条件 166 | 167 | ```go 168 | // 多种等待条件 169 | err := page.Chain(). 170 | WaitForLoad(). // 等待页面加载 171 | WaitForStable(). // 等待 DOM 稳定 172 | WaitForElement(".result"). // 等待元素出现 173 | WaitForText("操作完成"). // 等待文本出现 174 | Error() 175 | ``` 176 | 177 | ### 🎯 便利方法 178 | 179 | ```go 180 | // 快速填充表单 181 | err := browser.QuickFill("https://example.com/form", map[string]string{ 182 | "#username": "admin", 183 | "#password": "password", 184 | }, "#submit") 185 | 186 | // 快速点击 187 | err := browser.QuickClick("https://example.com", "#download") 188 | 189 | // 快速搜索 190 | err := browser.QuickSearch("https://example.com", "#search", "关键词") 191 | ``` 192 | 193 | ### 📋 错误处理 194 | 195 | #### 链式错误处理 196 | 197 | ```go 198 | // 使用 Error() 进行错误检查 199 | err := page.Chain(). 200 | NavigateTo("https://example.com"). 201 | ClickOn("#button"). 202 | Error() 203 | 204 | if err != nil { 205 | // 检查错误类型 206 | if browser.IsTimeoutError(err) { 207 | fmt.Println("操作超时") 208 | } 209 | if browser.IsRetryableError(err) { 210 | fmt.Println("可以重试") 211 | } 212 | } 213 | 214 | // 使用 MustComplete() (失败时 panic) 215 | page.Chain(). 216 | NavigateTo("https://example.com"). 217 | ClickOn("#button"). 218 | MustComplete() 219 | ``` 220 | 221 | #### 重试机制 222 | 223 | ```go 224 | // 使用默认重试策略 225 | err := browser.WithRetry(browser.DefaultRetry, func() error { 226 | return page.Chain(). 227 | NavigateTo("https://unstable-site.com"). 228 | Error() 229 | }) 230 | 231 | // 自定义重试策略 232 | customRetry := browser.ErrorRetry{ 233 | MaxAttempts: 5, 234 | Delay: 2 * time.Second, 235 | Backoff: 1.5, 236 | } 237 | 238 | err = browser.WithRetry(customRetry, func() error { 239 | return page.ClickOn("#flaky-button") 240 | }) 241 | ``` 242 | 243 | ## 🔧 高级用法 244 | 245 | ### 条件操作 246 | 247 | ```go 248 | // 检查元素是否存在 249 | has, element := page.HasElement("#login-form") 250 | if has { 251 | // 执行登录 252 | err := element.Chain(). 253 | FindChild("#username").Type("admin"). 254 | FindChild("#password").Type("password"). 255 | FindChild("#submit").Click(). 256 | Error() 257 | } 258 | ``` 259 | 260 | ### 自定义操作 261 | 262 | ```go 263 | // 在链式调用中执行自定义逻辑 264 | err := page.Chain(). 265 | WaitForLoad(). 266 | Execute(func(page *browser.Page) error { 267 | // 自定义操作 268 | return page.page.KeyActions(). 269 | Press(input.ControlLeft). 270 | Type(input.KeyA). 271 | Do() 272 | }). 273 | Type("新内容"). 274 | Error() 275 | ``` 276 | 277 | ### 克隆配置 278 | 279 | ```go 280 | // 创建基础配置 281 | baseBuilder := browser.NewBrowser(). 282 | Preset(browser.PresetTesting). 283 | WithTimeout(30 * time.Second) 284 | 285 | // 克隆并修改 286 | browser1 := baseBuilder.Clone(). 287 | WithUserAgent("Bot1/1.0"). 288 | MustBuild() 289 | 290 | browser2 := baseBuilder.Clone(). 291 | WithUserAgent("Bot2/1.0"). 292 | WithHeadless(false). 293 | MustBuild() 294 | ``` 295 | 296 | ## 🆚 新旧 API 对比 297 | 298 | ### 旧 API (仍然支持) 299 | 300 | ```go 301 | browser, err := browser.New(func(o *browser.Options) { 302 | o.Headless = false 303 | o.UserAgent = "Chrome/120.0" 304 | o.Timeout = 30 * time.Second 305 | o.Flags = map[string]string{"no-sandbox": ""} 306 | }) 307 | 308 | err = browser.Open("https://example.com", func(page *browser.Page) error { 309 | element, err := page.Element("#input") 310 | if err != nil { return err } 311 | return element.InputText("hello") 312 | }) 313 | ``` 314 | 315 | ### 新 API (推荐) 316 | 317 | ```go 318 | browser := browser.NewBrowser(). 319 | WithHeadless(false). 320 | WithUserAgent("Chrome/120.0"). 321 | WithTimeout(30 * time.Second). 322 | WithFlag("no-sandbox", ""). 323 | MustBuild() 324 | 325 | err := browser.Open("https://example.com", func(page *browser.Page) error { 326 | return page.Chain(). 327 | FindElement("#input"). 328 | Type("hello"). 329 | Error() 330 | }) 331 | ``` 332 | 333 | ## 🛠️ 最佳实践 334 | 335 | ### 1. 错误处理 336 | 337 | ```go 338 | // 推荐:使用 Error() 进行显式错误处理 339 | err := page.Chain(). 340 | NavigateTo(url). 341 | Error() 342 | if err != nil { 343 | log.Printf("导航失败: %v", err) 344 | // 处理错误或重试 345 | } 346 | 347 | // 开发调试:使用 MustComplete() 快速失败 348 | page.Chain(). 349 | NavigateTo(url). 350 | MustComplete() // 失败时 panic,便于调试 351 | ``` 352 | 353 | ### 2. 性能优化 354 | 355 | ```go 356 | // 生产环境使用隐形模式 357 | browser := browser.NewBrowser(). 358 | Preset(browser.PresetProduction). 359 | WithStealth(true). 360 | MustBuild() 361 | 362 | // 合理设置超时 363 | browser := browser.NewBrowser(). 364 | WithTimeout(10 * time.Second). // 全局超时 365 | MustBuild() 366 | 367 | // 页面级超时 368 | page.WithTimeout(5 * time.Second, func(p *browser.Page) error { 369 | return p.Chain().FindElement("#fast-element").Click().Error() 370 | }) 371 | ``` 372 | 373 | ### 3. 资源管理 374 | 375 | ```go 376 | // 确保浏览器正确关闭 377 | browser := browser.NewBrowser().MustBuild() 378 | defer browser.Close() // 重要:释放资源 379 | 380 | // 页面资源清理 381 | err := browser.Open(url, func(page *browser.Page) error { 382 | defer func() { 383 | // 页面特定的清理工作 384 | }() 385 | return page.Chain()./* 操作 */.Error() 386 | }) 387 | ``` 388 | 389 | ## 🐛 故障排除 390 | 391 | ### 常见问题 392 | 393 | 1. **浏览器启动失败** 394 | ```bash 395 | # Linux 安装依赖 396 | sudo apt-get install -y libnss3 libxss1 libasound2t64 libxtst6 libgtk-3-0 libgbm1 397 | ``` 398 | 399 | 2. **元素找不到** 400 | ```go 401 | // 增加等待时间 402 | err := page.Chain(). 403 | WaitForElement("#slow-element", 10*time.Second). 404 | Error() 405 | ``` 406 | 407 | 3. **操作太快** 408 | ```go 409 | // 添加延迟 410 | err := page.Chain(). 411 | Click("#button"). 412 | Sleep(2 * time.Second). 413 | Error() 414 | ``` 415 | 416 | ## 📚 更多资源 417 | 418 | - [完整 API 文档](https://docs.73zls.com/zlsgo/#/c9e16ee075214cf2a9df1f7093aece58) 419 | 420 | -------------------------------------------------------------------------------- /fluent.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/go-rod/rod/lib/input" 8 | "github.com/go-rod/rod/lib/proto" 9 | ) 10 | 11 | // FluentPage 流式页面操作接口 12 | type FluentPage struct { 13 | page *Page 14 | err error 15 | } 16 | 17 | // FluentElement 流式元素操作接口 18 | type FluentElement struct { 19 | element *Element 20 | err error 21 | } 22 | 23 | // Chain 为Page添加链式调用支持 24 | func (p *Page) Chain() *FluentPage { 25 | return &FluentPage{page: p} 26 | } 27 | 28 | // Fluent 为Element添加链式调用支持 29 | func (e *Element) Chain() *FluentElement { 30 | return &FluentElement{element: e} 31 | } 32 | 33 | // ===== FluentPage 方法 ===== 34 | 35 | // NavigateTo 导航到指定URL 36 | func (fp *FluentPage) NavigateTo(url string) *FluentPage { 37 | if fp.err != nil { 38 | return fp 39 | } 40 | fp.err = fp.page.NavigateWaitLoad(url) 41 | return fp 42 | } 43 | 44 | // WaitForLoad 等待页面加载完成 45 | func (fp *FluentPage) WaitForLoad() *FluentPage { 46 | if fp.err != nil { 47 | return fp 48 | } 49 | fp.err = fp.page.WaitLoad() 50 | return fp 51 | } 52 | 53 | // WaitForStable 等待DOM稳定 54 | func (fp *FluentPage) WaitForStable(diff ...float64) *FluentPage { 55 | if fp.err != nil { 56 | return fp 57 | } 58 | fp.err = fp.page.WaitDOMStable(diff...) 59 | return fp 60 | } 61 | 62 | // WaitForElement 等待元素出现 63 | func (fp *FluentPage) WaitForElement(selector string, timeout ...time.Duration) *FluentPage { 64 | if fp.err != nil { 65 | return fp 66 | } 67 | 68 | page := fp.page 69 | if len(timeout) > 0 { 70 | page = page.Timeout(timeout[0]) 71 | } 72 | 73 | _, fp.err = page.Element(selector) 74 | return fp 75 | } 76 | 77 | // FindElement 查找元素并返回流式元素操作 78 | func (fp *FluentPage) FindElement(selector string) *FluentElement { 79 | if fp.err != nil { 80 | return &FluentElement{err: fp.err} 81 | } 82 | 83 | element, err := fp.page.Element(selector) 84 | return &FluentElement{element: element, err: err} 85 | } 86 | 87 | // ClickOn 点击指定选择器的元素 88 | func (fp *FluentPage) ClickOn(selector string) *FluentPage { 89 | if fp.err != nil { 90 | return fp 91 | } 92 | 93 | element, err := fp.page.Element(selector) 94 | if err != nil { 95 | fp.err = err 96 | return fp 97 | } 98 | 99 | fp.err = element.Click() 100 | return fp 101 | } 102 | 103 | // TypeInto 在指定选择器的元素中输入文本 104 | func (fp *FluentPage) TypeInto(selector, text string) *FluentPage { 105 | if fp.err != nil { 106 | return fp 107 | } 108 | 109 | element, err := fp.page.Element(selector) 110 | if err != nil { 111 | fp.err = err 112 | return fp 113 | } 114 | 115 | fp.err = element.InputText(text) 116 | return fp 117 | } 118 | 119 | // FillForm 批量填充表单 120 | func (fp *FluentPage) FillForm(data map[string]string) *FluentPage { 121 | if fp.err != nil { 122 | return fp 123 | } 124 | 125 | for selector, value := range data { 126 | element, err := fp.page.Element(selector) 127 | if err != nil { 128 | fp.err = fmt.Errorf("找不到元素 %s: %w", selector, err) 129 | return fp 130 | } 131 | 132 | if err := element.InputText(value, true); err != nil { 133 | fp.err = fmt.Errorf("输入文本到 %s 失败: %w", selector, err) 134 | return fp 135 | } 136 | } 137 | 138 | return fp 139 | } 140 | 141 | // SubmitForm 提交表单 142 | func (fp *FluentPage) SubmitForm(formSelector ...string) *FluentPage { 143 | if fp.err != nil { 144 | return fp 145 | } 146 | 147 | selector := "form" 148 | if len(formSelector) > 0 { 149 | selector = formSelector[0] 150 | } 151 | 152 | form, err := fp.page.Element(selector) 153 | if err != nil { 154 | fp.err = err 155 | return fp 156 | } 157 | 158 | // 查找提交按钮或直接提交表单 159 | submitBtn, err := form.Element("input[type='submit'], button[type='submit'], button:not([type])") 160 | if err != nil { 161 | // 如果找不到提交按钮,尝试按Enter键 162 | fp.err = fp.page.page.KeyActions().Type(input.Enter).Do() 163 | } else { 164 | fp.err = submitBtn.Click() 165 | } 166 | 167 | return fp 168 | } 169 | 170 | // ScrollTo 滚动到指定元素 171 | func (fp *FluentPage) ScrollTo(selector string) *FluentPage { 172 | if fp.err != nil { 173 | return fp 174 | } 175 | 176 | element, err := fp.page.Element(selector) 177 | if err != nil { 178 | fp.err = err 179 | return fp 180 | } 181 | 182 | fp.err = fp.page.NaturalScroll(element, time.Second) 183 | return fp 184 | } 185 | 186 | // WaitForText 等待页面包含指定文本 187 | func (fp *FluentPage) WaitForText(text string, timeout ...time.Duration) *FluentPage { 188 | if fp.err != nil { 189 | return fp 190 | } 191 | 192 | page := fp.page 193 | if len(timeout) > 0 { 194 | page = page.Timeout(timeout[0]) 195 | } 196 | 197 | _, fp.err = page.Search(text) 198 | return fp 199 | } 200 | 201 | // Sleep 等待指定时间 202 | func (fp *FluentPage) Sleep(duration time.Duration) *FluentPage { 203 | if fp.err != nil { 204 | return fp 205 | } 206 | time.Sleep(duration) 207 | return fp 208 | } 209 | 210 | // Execute 执行自定义函数 211 | func (fp *FluentPage) Execute(fn func(*Page) error) *FluentPage { 212 | if fp.err != nil { 213 | return fp 214 | } 215 | fp.err = fn(fp.page) 216 | return fp 217 | } 218 | 219 | // ===== FluentElement 方法 ===== 220 | 221 | // Click 点击元素 222 | func (fe *FluentElement) Click() *FluentElement { 223 | if fe.err != nil { 224 | return fe 225 | } 226 | fe.err = fe.element.Click() 227 | return fe 228 | } 229 | 230 | // DoubleClick 双击元素 231 | func (fe *FluentElement) DoubleClick() *FluentElement { 232 | if fe.err != nil { 233 | return fe 234 | } 235 | fe.err = fe.element.element.Click(proto.InputMouseButtonLeft, 2) 236 | return fe 237 | } 238 | 239 | // RightClick 右键点击元素 240 | func (fe *FluentElement) RightClick() *FluentElement { 241 | if fe.err != nil { 242 | return fe 243 | } 244 | fe.err = fe.element.Click(proto.InputMouseButtonRight) 245 | return fe 246 | } 247 | 248 | // Type 输入文本 249 | func (fe *FluentElement) Type(text string) *FluentElement { 250 | if fe.err != nil { 251 | return fe 252 | } 253 | fe.err = fe.element.InputText(text) 254 | return fe 255 | } 256 | 257 | // Clear 清空元素内容 258 | func (fe *FluentElement) Clear() *FluentElement { 259 | if fe.err != nil { 260 | return fe 261 | } 262 | fe.err = fe.element.element.SelectAllText() 263 | if fe.err == nil { 264 | fe.err = fe.element.element.Input("") 265 | } 266 | return fe 267 | } 268 | 269 | // ClearAndType 清空并输入新文本 270 | func (fe *FluentElement) ClearAndType(text string) *FluentElement { 271 | return fe.Clear().Type(text) 272 | } 273 | 274 | // PressEnter 按回车键 275 | func (fe *FluentElement) PressEnter() *FluentElement { 276 | if fe.err != nil { 277 | return fe 278 | } 279 | fe.err = fe.element.InputEnter() 280 | return fe 281 | } 282 | 283 | // PressKey 按指定键 284 | func (fe *FluentElement) PressKey(keys ...input.Key) *FluentElement { 285 | if fe.err != nil { 286 | return fe 287 | } 288 | fe.err = fe.element.page.page.KeyActions().Press(keys...).Do() 289 | return fe 290 | } 291 | 292 | // Focus 聚焦元素 293 | func (fe *FluentElement) Focus() *FluentElement { 294 | if fe.err != nil { 295 | return fe 296 | } 297 | fe.err = fe.element.Focus() 298 | return fe 299 | } 300 | 301 | // Hover 悬停在元素上 302 | func (fe *FluentElement) Hover() *FluentElement { 303 | if fe.err != nil { 304 | return fe 305 | } 306 | fe.err = fe.element.element.Hover() 307 | return fe 308 | } 309 | 310 | // ScrollIntoView 滚动到元素可见位置 311 | func (fe *FluentElement) ScrollIntoView() *FluentElement { 312 | if fe.err != nil { 313 | return fe 314 | } 315 | fe.err = fe.element.element.ScrollIntoView() 316 | return fe 317 | } 318 | 319 | // WaitVisible 等待元素可见 320 | func (fe *FluentElement) WaitVisible() *FluentElement { 321 | if fe.err != nil { 322 | return fe 323 | } 324 | fe.err = fe.element.element.WaitVisible() 325 | return fe 326 | } 327 | 328 | // WaitInvisible 等待元素不可见 329 | func (fe *FluentElement) WaitInvisible() *FluentElement { 330 | if fe.err != nil { 331 | return fe 332 | } 333 | fe.err = fe.element.element.WaitInvisible() 334 | return fe 335 | } 336 | 337 | // FindChild 查找子元素 338 | func (fe *FluentElement) FindChild(selector string) *FluentElement { 339 | if fe.err != nil { 340 | return &FluentElement{err: fe.err} 341 | } 342 | 343 | child, err := fe.element.Element(selector) 344 | return &FluentElement{element: child, err: err} 345 | } 346 | 347 | // Execute 在元素上执行自定义函数 348 | func (fe *FluentElement) Execute(fn func(*Element) error) *FluentElement { 349 | if fe.err != nil { 350 | return fe 351 | } 352 | fe.err = fn(fe.element) 353 | return fe 354 | } 355 | 356 | // ===== 结果获取方法 ===== 357 | 358 | // Error 获取操作中的错误 359 | func (fp *FluentPage) Error() error { 360 | return fp.err 361 | } 362 | 363 | // Page 获取页面实例 364 | func (fp *FluentPage) Page() *Page { 365 | return fp.page 366 | } 367 | 368 | // MustComplete 必须成功完成,否则panic 369 | func (fp *FluentPage) MustComplete() *Page { 370 | if fp.err != nil { 371 | panic(fp.err) 372 | } 373 | return fp.page 374 | } 375 | 376 | // Complete 完成操作链,返回结果 377 | func (fp *FluentPage) Complete() (*Page, error) { 378 | return fp.page, fp.err 379 | } 380 | 381 | // Error 获取操作中的错误 382 | func (fe *FluentElement) Error() error { 383 | return fe.err 384 | } 385 | 386 | // Element 获取元素实例 387 | func (fe *FluentElement) Element() *Element { 388 | return fe.element 389 | } 390 | 391 | // MustComplete 必须成功完成,否则panic 392 | func (fe *FluentElement) MustComplete() *Element { 393 | if fe.err != nil { 394 | panic(fe.err) 395 | } 396 | return fe.element 397 | } 398 | 399 | // Complete 完成操作链,返回结果 400 | func (fe *FluentElement) Complete() (*Element, error) { 401 | return fe.element, fe.err 402 | } 403 | 404 | // ===== 便利方法 ===== 405 | 406 | // QuickFill 快速填充表单的便利方法 407 | func (b *Browser) QuickFill(url string, formData map[string]string, submitSelector ...string) error { 408 | return b.Open(url, func(p *Page) error { 409 | chain := p.Chain(). 410 | WaitForLoad(). 411 | FillForm(formData) 412 | 413 | if len(submitSelector) > 0 { 414 | chain = chain.ClickOn(submitSelector[0]) 415 | } else { 416 | chain = chain.SubmitForm() 417 | } 418 | 419 | return chain.Error() 420 | }) 421 | } 422 | 423 | // QuickClick 快速点击的便利方法 424 | func (b *Browser) QuickClick(url, selector string) error { 425 | return b.Open(url, func(p *Page) error { 426 | return p.Chain(). 427 | WaitForLoad(). 428 | ClickOn(selector). 429 | Error() 430 | }) 431 | } 432 | 433 | // QuickSearch 快速搜索的便利方法 434 | func (b *Browser) QuickSearch(url, searchSelector, query string) error { 435 | return b.Open(url, func(p *Page) error { 436 | return p.Chain(). 437 | WaitForLoad(). 438 | TypeInto(searchSelector, query). 439 | PressEnter(). 440 | Error() 441 | }) 442 | } 443 | 444 | // PressEnter 为FluentPage添加回车键支持 445 | func (fp *FluentPage) PressEnter() *FluentPage { 446 | if fp.err != nil { 447 | return fp 448 | } 449 | fp.err = fp.page.page.KeyActions().Type(input.Enter).Do() 450 | return fp 451 | } -------------------------------------------------------------------------------- /page.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "errors" 7 | "math" 8 | "math/rand" 9 | "sync" 10 | "time" 11 | 12 | "github.com/go-rod/rod" 13 | "github.com/go-rod/rod/lib/devices" 14 | "github.com/go-rod/rod/lib/proto" 15 | "github.com/sohaha/zlsgo/zerror" 16 | "github.com/sohaha/zlsgo/zstring" 17 | "github.com/sohaha/zlsgo/zutil" 18 | "github.com/ysmood/gson" 19 | ) 20 | 21 | type Page struct { 22 | ctx context.Context 23 | page *rod.Page 24 | browser *Browser 25 | Options PageOptions 26 | timeout time.Duration 27 | } 28 | 29 | func (page *Page) FromROD(p *rod.Page) *Page { 30 | return &Page{ 31 | page: p, 32 | browser: page.browser, 33 | ctx: page.ctx, 34 | Options: page.Options, 35 | } 36 | } 37 | 38 | // ROD 获取 rod 实例 39 | func (page *Page) ROD() *rod.Page { 40 | return page.page 41 | } 42 | 43 | // Browser 获取浏览器实例 44 | func (page *Page) Browser() *Browser { 45 | return page.browser 46 | } 47 | 48 | // Close 关闭页面 49 | func (page *Page) Close() error { 50 | return page.page.Close() 51 | } 52 | 53 | // Value 获取上下文 54 | func (page *Page) Value(key any) any { 55 | return page.ctx.Value(key) 56 | } 57 | 58 | // WithValue 设置上下文 59 | func (page *Page) WithValue(key any, value any) { 60 | page.ctx = context.WithValue(page.ctx, key, value) 61 | } 62 | 63 | // NavigateComplete 等待页面加载完成 64 | func (page *Page) NavigateComplete(fn func(), d ...time.Duration) { 65 | wait := page.Timeout(d...).page.MustWaitNavigation() 66 | fn() 67 | wait() 68 | } 69 | 70 | type OpenType int 71 | 72 | const ( 73 | OpenTypeCurrent OpenType = iota 74 | OpenTypeNewTab 75 | OpenTypeSpa 76 | ) 77 | 78 | // WaitOpen 等待页面打开,注意手动关闭新页面 79 | func (page *Page) WaitOpen(openType OpenType, fn func() error, d ...time.Duration) (*Page, error) { 80 | var wait func() (*Page, error) 81 | if openType == OpenTypeNewTab { 82 | waitNavigation := page.waitOpen(d...) 83 | wait = func() (*Page, error) { 84 | nPage, err := waitNavigation() 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | newPage := *page 90 | newPage.page = nPage 91 | if newPage.ctx != nil { 92 | newPage.page = newPage.page.Context(newPage.ctx) 93 | } 94 | _, err = nPage.Activate() 95 | if err != nil { 96 | nPage.Close() 97 | return nil, err 98 | } 99 | 100 | return &newPage, nil 101 | } 102 | } else { 103 | waitNavigation := page.Timeout(d...).page.WaitNavigation(proto.PageLifecycleEventNameNetworkAlmostIdle) 104 | wait = func() (*Page, error) { 105 | waitNavigation() 106 | return page, nil 107 | } 108 | } 109 | 110 | err := fn() 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | return wait() 116 | } 117 | 118 | func (p *Page) waitOpen(d ...time.Duration) func() (*rod.Page, error) { 119 | var ( 120 | targetID proto.TargetTargetID 121 | mu sync.Mutex 122 | ) 123 | 124 | b := p.browser.Browser.Context(p.ctx) 125 | wait := b.Timeout(p.GetTimeout(d...)).EachEvent(func(e *proto.TargetTargetCreated) bool { 126 | mu.Lock() 127 | defer mu.Unlock() 128 | 129 | if targetID == "" { 130 | targetID = e.TargetInfo.TargetID 131 | } 132 | 133 | return e.TargetInfo.OpenerID == p.page.TargetID 134 | }) 135 | return func() (*rod.Page, error) { 136 | wait() 137 | return b.PageFromTarget(targetID) 138 | } 139 | } 140 | 141 | // WaitLoad 等待页面加载 142 | func (page *Page) WaitLoad(d ...time.Duration) (err error) { 143 | _, err = page.Timeout(d...).page.Eval(jsWaitLoad) 144 | return 145 | } 146 | 147 | // WaitDOMStable 等待 DOM 稳定 148 | func (page *Page) WaitDOMStable(diff ...float64) (err error) { 149 | t := page.GetTimeout() 150 | if len(diff) > 0 { 151 | return page.page.Timeout(t).WaitDOMStable(time.Second, diff[0]) 152 | } 153 | return page.page.Timeout(t).WaitDOMStable(time.Second, 0.01) 154 | } 155 | 156 | // NavigateLoad 导航到新 url 157 | func (page *Page) NavigateLoad(url string) (err error) { 158 | if url == "" { 159 | url = "about:blank" 160 | } 161 | 162 | if err := page.Timeout().page.Navigate(url); err != nil { 163 | return err 164 | } 165 | return nil 166 | } 167 | 168 | // NavigateWaitLoad 导航到新 url,并等待页面加载 169 | func (page *Page) NavigateWaitLoad(url string) (err error) { 170 | err = page.NavigateLoad(url) 171 | 172 | if err == nil && url != "about:blank" { 173 | _, err = page.Timeout().page.Eval(jsWaitDOMContentLoad) 174 | } 175 | 176 | return err 177 | } 178 | 179 | // WaitNavigation 等待页面切换 180 | func (page *Page) WaitNavigation(fn func() error, d ...time.Duration) error { 181 | wait := page.Timeout(d...).page.MustWaitNavigation() 182 | err := fn() 183 | if err != nil { 184 | return err 185 | } 186 | wait() 187 | return nil 188 | } 189 | 190 | // WithTimeout 包裹一个内置的超时处理 191 | func (page *Page) WithTimeout(d time.Duration, fn func(page *Page) error) error { 192 | return fn(page.Timeout(d)) 193 | } 194 | 195 | // MustWithTimeout 包裹一个内置的超时处理,如果超时会panic 196 | func (page *Page) MustWithTimeout(d time.Duration, fn func(page *Page) error) { 197 | err := page.WithTimeout(d, fn) 198 | if err != nil { 199 | panic(err) 200 | } 201 | } 202 | 203 | func (page *Page) GetTimeout(d ...time.Duration) time.Duration { 204 | if len(d) > 0 { 205 | return d[0] 206 | } else if page.timeout != 0 { 207 | return page.timeout 208 | } else if page.Options.Timeout != 0 { 209 | return page.Options.Timeout 210 | } else if page.browser.options.Timeout != 0 { 211 | return page.browser.options.Timeout 212 | } 213 | 214 | return page.timeout 215 | } 216 | 217 | // Timeout 设置超时 218 | func (page *Page) Timeout(d ...time.Duration) *Page { 219 | rpage := page.page 220 | if page.timeout != 0 { 221 | rpage = rpage.CancelTimeout() 222 | } 223 | 224 | var timeout time.Duration 225 | if len(d) > 0 { 226 | timeout = d[0] 227 | } else { 228 | timeout = page.GetTimeout() 229 | } 230 | 231 | if timeout != 0 && timeout >= 0 { 232 | rpage = rpage.Timeout(timeout) 233 | } 234 | 235 | return &Page{ 236 | ctx: page.ctx, 237 | page: rpage, 238 | Options: page.Options, 239 | browser: page.browser, 240 | timeout: timeout, 241 | } 242 | } 243 | 244 | // HasElement 检查元素是否存在,不会等待元素出现 245 | func (page *Page) HasElement(selector string) (bool, *Element) { 246 | has, ele, _ := page.page.Has(selector) 247 | if !has { 248 | return false, nil 249 | } 250 | 251 | return true, &Element{ 252 | element: ele, 253 | page: page, 254 | } 255 | } 256 | 257 | // Element 获取元素,会等待元素出现 258 | func (page *Page) Element(selector string, jsRegex ...string) (ele *Element, err error) { 259 | var e *rod.Element 260 | 261 | if len(jsRegex) == 0 { 262 | e, err = page.page.Element(selector) 263 | } else { 264 | e, err = page.page.ElementByJS(rod.Eval(selector, jsRegex[0])) 265 | } 266 | if err != nil { 267 | return 268 | } 269 | 270 | return &Element{ 271 | element: e, 272 | page: page, 273 | }, nil 274 | } 275 | 276 | func (page *Page) MustElement(selector string, jsRegex ...string) (ele *Element) { 277 | var err error 278 | element, err := page.Element(selector, jsRegex...) 279 | if err != nil { 280 | panic(err) 281 | } 282 | 283 | return element 284 | } 285 | 286 | func (page *Page) Elements(selector string, filter ...string) (elements Elements, has bool) { 287 | _, err := page.Timeout().page.Element(selector) 288 | if err != nil { 289 | if errors.Is(err, &rod.ElementNotFoundError{}) { 290 | return Elements{}, false 291 | } 292 | return 293 | } 294 | 295 | es, _ := page.page.Elements(selector) 296 | has = len(es) > 0 297 | 298 | f := filterElements(filter...) 299 | for _, e := range es { 300 | if ok := f(e); !ok { 301 | continue 302 | } 303 | elements = append(elements, &Element{ 304 | element: e, 305 | page: page, 306 | }) 307 | } 308 | 309 | return 310 | } 311 | 312 | func (page *Page) MustElements(selector string, filter ...string) (elements Elements) { 313 | element, has := page.Elements(selector, filter...) 314 | if !has { 315 | panic(&rod.ElementNotFoundError{}) 316 | } 317 | 318 | return element 319 | } 320 | 321 | type RaceElementFunc struct { 322 | Element func(p *Page) (bool, *Element) 323 | Handle func(element *Element) (retry bool, err error) 324 | } 325 | 326 | // RaceElement 等待多个元素出现,返回第一个出现的元素 327 | func (page *Page) RaceElement(elements map[string]RaceElementFunc) (name string, ele *Element, err error) { 328 | info, ierr := page.page.Info() 329 | if ierr != nil { 330 | err = ierr 331 | return 332 | } 333 | 334 | race, retry := page.page.Race(), false 335 | for key := range elements { 336 | k := key 337 | v := elements[k] 338 | race = race.ElementFunc(func(p *rod.Page) (*rod.Element, error) { 339 | var ( 340 | ele *Element 341 | has bool 342 | ) 343 | err := zerror.TryCatch(func() (err error) { 344 | has, ele = v.Element(&Page{page: p, ctx: page.ctx, Options: page.Options, browser: page.browser}) 345 | return err 346 | }) 347 | if !has { 348 | return nil, &rod.ElementNotFoundError{} 349 | } 350 | if err != nil { 351 | elementNotFoundError := &rod.ElementNotFoundError{} 352 | if err.Error() == elementNotFoundError.Error() { 353 | return nil, elementNotFoundError 354 | } 355 | return nil, err 356 | } 357 | return ele.element, nil 358 | }).MustHandle(func(element *rod.Element) { 359 | name = k 360 | ele = &Element{ 361 | element: element, 362 | page: page, 363 | } 364 | if v.Handle != nil { 365 | if e := zerror.TryCatch(func() error { 366 | retry, err = v.Handle(ele) 367 | return nil 368 | }); e != nil { 369 | retry = true 370 | } 371 | } 372 | }) 373 | } 374 | 375 | _, doErr := race.Do() 376 | if err == nil && doErr != nil { 377 | err = doErr 378 | } 379 | 380 | if err != nil { 381 | name = "" 382 | if retry { 383 | t := page.GetTimeout() 384 | err = page.Timeout(t).NavigateWaitLoad(info.URL) 385 | if err == nil { 386 | _ = page.Timeout(t).WaitDOMStable(0.1) 387 | return page.Timeout(t).RaceElement(elements) 388 | } 389 | } 390 | } 391 | 392 | return 393 | } 394 | 395 | // Search 搜索元素 396 | func (page *Page) Search(query string) (ele *Element, err error) { 397 | sr, err := page.page.Search(query) 398 | if err != nil { 399 | return nil, err 400 | } 401 | sr.Release() 402 | 403 | ele = &Element{ 404 | element: sr.First, 405 | page: page, 406 | } 407 | 408 | return ele, nil 409 | } 410 | 411 | // MustSearch 搜索元素,如果出错则 panic 412 | func (page *Page) MustSearch(query string) (ele *Element) { 413 | ele, err := page.Search(query) 414 | if err != nil { 415 | panic(err) 416 | } 417 | return ele 418 | } 419 | 420 | type PageOptions struct { 421 | Ctx context.Context 422 | Network func(p *proto.NetworkEmulateNetworkConditions) 423 | Hijack map[string]HijackProcess 424 | Device devices.Device 425 | Timeout time.Duration 426 | MaxTime time.Duration 427 | Keep bool 428 | TriggerFavicon bool 429 | } 430 | 431 | func (b *Browser) Open(url string, process func(*Page) error, opts ...func(o *PageOptions)) error { 432 | if b.err != nil { 433 | return b.err 434 | } 435 | 436 | page, err := b.Browser.Page(proto.TargetCreateTarget{}) 437 | if err != nil { 438 | return zerror.With(err, "failed to create a new tab") 439 | } 440 | 441 | if b.userAgent != nil { 442 | _ = page.SetUserAgent(b.userAgent) 443 | } 444 | 445 | p := &Page{ 446 | page: page, 447 | browser: b, 448 | ctx: page.GetContext(), 449 | } 450 | 451 | o := zutil.Optional(PageOptions{ 452 | Timeout: time.Second * 120, 453 | TriggerFavicon: true, 454 | // Device: devices.LaptopWithMDPIScreen, 455 | }, opts...) 456 | { 457 | if o.Ctx != nil { 458 | p.page = p.page.Context(o.Ctx) 459 | } 460 | 461 | if o.TriggerFavicon { 462 | _ = p.page.TriggerFavicon() 463 | } 464 | 465 | if o.Device.Title != "" { 466 | p.page = p.page.MustEmulate(o.Device) 467 | } 468 | 469 | if o.Network != nil { 470 | p.page.EnableDomain(proto.NetworkEnable{}) 471 | network := proto.NetworkEmulateNetworkConditions{ 472 | Offline: false, 473 | Latency: 0, 474 | DownloadThroughput: -1, 475 | UploadThroughput: -1, 476 | ConnectionType: proto.NetworkConnectionTypeNone, 477 | } 478 | o.Network(&network) 479 | _ = network.Call(p.page) 480 | } 481 | 482 | if b.options.Hijack != nil || len(o.Hijack) > 0 { 483 | stop := p.hijack(func(router *rod.HijackRouter) { 484 | for k, v := range o.Hijack { 485 | _ = router.Add(k, "", func(ctx *rod.Hijack) { 486 | hijaclProcess(newHijacl(ctx, b.client), v) 487 | }) 488 | } 489 | 490 | if b.options.Hijack != nil { 491 | _ = router.Add("*", "", func(ctx *rod.Hijack) { 492 | hijaclProcess(newHijacl(ctx, b.client), b.options.Hijack) 493 | }) 494 | } 495 | 496 | _ = router.Add("*", "", func(ctx *rod.Hijack) { 497 | hijaclProcess(newHijacl(ctx, b.client), func(router *Hijack) (stop bool) { 498 | return false 499 | }) 500 | }) 501 | }) 502 | if !o.Keep { 503 | defer func() { 504 | _ = stop() 505 | }() 506 | } 507 | } 508 | } 509 | 510 | defer func() { 511 | if !o.Keep || err != nil { 512 | _ = p.page.Close() 513 | } 514 | }() 515 | 516 | p.Options = o 517 | 518 | if p.browser.options.Stealth && len(stealth) > 0 { 519 | p.page.MustEvalOnNewDocument(`(()=>{` + stealth + `})()`) 520 | } 521 | 522 | for i := range b.options.Scripts { 523 | p.page.MustEvalOnNewDocument(b.options.Scripts[i]) 524 | } 525 | 526 | if err = p.NavigateLoad(url); err != nil { 527 | return zerror.With(err, "failed to open the page") 528 | } 529 | 530 | if process == nil { 531 | return nil 532 | } 533 | 534 | return zerror.TryCatch(func() error { 535 | if p.Options.MaxTime > 0 { 536 | go func() { 537 | timer := time.NewTimer(p.Options.MaxTime) 538 | defer timer.Stop() 539 | select { 540 | case <-timer.C: 541 | p.page.Close() 542 | case <-p.ctx.Done(): 543 | case <-p.page.GetContext().Done(): 544 | } 545 | }() 546 | } 547 | 548 | return process(p) 549 | }) 550 | } 551 | 552 | func hijaclProcess(h *Hijack, p HijackProcess) { 553 | if h.CustomState != nil { 554 | return 555 | } 556 | 557 | h.Skip = false 558 | 559 | stop := p(h) 560 | 561 | if h.abort { 562 | h.CustomState = true 563 | h.Hijack.Response.Fail(proto.NetworkErrorReasonBlockedByClient) 564 | return 565 | } 566 | 567 | if h.Skip { 568 | return 569 | } 570 | 571 | if !stop { 572 | h.ContinueRequest(&proto.FetchContinueRequest{}) 573 | } else { 574 | h.Skip = true 575 | } 576 | 577 | h.CustomState = true 578 | } 579 | 580 | func (page *Page) hijack(fn func(router *rod.HijackRouter)) func() error { 581 | router := page.page.HijackRequests() 582 | fn(router) 583 | go router.Run() 584 | return router.Stop 585 | } 586 | 587 | func (page *Page) Reload() error { 588 | return page.page.Reload() 589 | } 590 | 591 | // ScrollStrategy 滚动策略类型 592 | type ScrollStrategy int 593 | 594 | const ( 595 | // ScrollStrategyAuto 自动选择滚动策略 596 | ScrollStrategyAuto ScrollStrategy = iota 597 | // ScrollStrategyCenter 将元素滚动到视口中心 598 | ScrollStrategyCenter 599 | // ScrollStrategyTop 将元素滚动到视口顶部 600 | ScrollStrategyTop 601 | // ScrollStrategyVisible 仅确保元素可见(最小滚动) 602 | ScrollStrategyVisible 603 | ) 604 | 605 | // NaturalScroll 自然滚动到元素位置 606 | func (page *Page) NaturalScroll(e *Element, expectedDuration time.Duration, horizontal ...bool) error { 607 | // 使用自动策略,根据元素类型自动选择最合适的滚动策略 608 | return page.NaturalScrollWithStrategy(e, expectedDuration, ScrollStrategyAuto, horizontal...) 609 | } 610 | 611 | // NaturalScrollWithStrategy 使用指定策略进行自然滚动 612 | func (page *Page) NaturalScrollWithStrategy(e *Element, expectedDuration time.Duration, strategy ScrollStrategy, horizontal ...bool) error { 613 | deadline := time.Now().Add(expectedDuration) 614 | box, err := e.Box() 615 | if err != nil { 616 | return err 617 | } 618 | 619 | var viewportInfo gson.JSON 620 | viewportInfo, err = page.EvalJS(`() => ({ 621 | scrollX: window.scrollX, 622 | scrollY: window.scrollY, 623 | innerWidth: window.innerWidth, 624 | innerHeight: window.innerHeight 625 | })`) 626 | if err != nil { 627 | return err 628 | } 629 | 630 | currentScrollX := float64(viewportInfo.Get("scrollX").Int()) 631 | currentScrollY := float64(viewportInfo.Get("scrollY").Int()) 632 | viewportWidth := float64(viewportInfo.Get("innerWidth").Int()) 633 | viewportHeight := float64(viewportInfo.Get("innerHeight").Int()) 634 | 635 | elementCenterX := box.X + box.Width/2 636 | elementCenterY := box.Y + box.Height/2 637 | 638 | targetScrollX := elementCenterX - viewportWidth/2 639 | targetScrollY := elementCenterY - viewportHeight/2 640 | 641 | horizontalScrollDistance := targetScrollX - currentScrollX 642 | verticalScrollDistance := targetScrollY - currentScrollY 643 | 644 | isHorizontal := false 645 | var scrollDistance float64 646 | 647 | if len(horizontal) > 0 { 648 | isHorizontal = horizontal[0] 649 | if isHorizontal { 650 | scrollDistance = horizontalScrollDistance 651 | } else { 652 | scrollDistance = verticalScrollDistance 653 | } 654 | } else { 655 | elementLeft := box.X 656 | elementRight := box.X + box.Width 657 | elementTop := box.Y 658 | elementBottom := box.Y + box.Height 659 | 660 | viewportLeft := currentScrollX 661 | viewportRight := currentScrollX + viewportWidth 662 | viewportTop := currentScrollY 663 | viewportBottom := currentScrollY + viewportHeight 664 | 665 | horizontalVisible := math.Max(0, math.Min(elementRight, viewportRight)-math.Max(elementLeft, viewportLeft)) / box.Width 666 | verticalVisible := math.Max(0, math.Min(elementBottom, viewportBottom)-math.Max(elementTop, viewportTop)) / box.Height 667 | 668 | if strategy == ScrollStrategyAuto { 669 | tagName, err := e.TagName() 670 | if err == nil { 671 | switch tagName { 672 | case "img", "video", "canvas", "svg": 673 | if horizontalVisible < 1.0 && verticalVisible < 1.0 { 674 | isHorizontal = horizontalVisible < verticalVisible 675 | } else if horizontalVisible < 1.0 { 676 | isHorizontal = true 677 | } else if verticalVisible < 1.0 { 678 | isHorizontal = false 679 | } else { 680 | isHorizontal = math.Abs(horizontalScrollDistance) > math.Abs(verticalScrollDistance) 681 | } 682 | case "input", "textarea", "select", "button": 683 | isHorizontal = false 684 | case "table", "thead", "tbody", "tr", "td", "th": 685 | if box.Width > viewportWidth*1.2 { 686 | isHorizontal = true 687 | } else { 688 | // 否则优先垂直滚 689 | isHorizontal = false 690 | } 691 | default: 692 | if (horizontalVisible < verticalVisible) || 693 | (math.Abs(horizontalScrollDistance) > math.Abs(verticalScrollDistance)*1.5) { 694 | isHorizontal = true 695 | } else { 696 | isHorizontal = false 697 | } 698 | } 699 | } else { 700 | if (horizontalVisible < verticalVisible) || 701 | (math.Abs(horizontalScrollDistance) > math.Abs(verticalScrollDistance)*1.5) { 702 | isHorizontal = true 703 | } else { 704 | isHorizontal = false 705 | } 706 | } 707 | } else if strategy == ScrollStrategyCenter { 708 | isHorizontal = math.Abs(horizontalScrollDistance) > math.Abs(verticalScrollDistance) 709 | } else if strategy == ScrollStrategyTop { 710 | isHorizontal = false 711 | } else if strategy == ScrollStrategyVisible { 712 | isHorizontal = horizontalVisible < verticalVisible 713 | } else { 714 | if (horizontalVisible < verticalVisible) || 715 | (math.Abs(horizontalScrollDistance) > math.Abs(verticalScrollDistance)*1.5) { 716 | isHorizontal = true 717 | } else { 718 | isHorizontal = false 719 | } 720 | } 721 | 722 | if isHorizontal { 723 | scrollDistance = horizontalScrollDistance 724 | } else { 725 | scrollDistance = verticalScrollDistance 726 | } 727 | } 728 | 729 | if expectedDuration == 0 || math.Abs(scrollDistance) < 100 { 730 | autoSteps := int(math.Max(8, math.Min(40, math.Abs(scrollDistance)/15))) 731 | 732 | if math.Abs(scrollDistance) > 50 { 733 | initialStep := scrollDistance * 0.1 * (0.8 + 0.4*rand.Float64()) 734 | initialStepCount := int(math.Max(3, math.Min(8, math.Abs(initialStep)/10))) 735 | 736 | if isHorizontal { 737 | page.page.Mouse.Scroll(initialStep, 0, initialStepCount) 738 | } else { 739 | page.page.Mouse.Scroll(0, initialStep, initialStepCount) 740 | } 741 | 742 | time.Sleep(time.Duration(20+rand.Intn(40)) * time.Millisecond) 743 | 744 | mainStep := scrollDistance - initialStep 745 | if isHorizontal { 746 | page.page.Mouse.Scroll(mainStep, 0, autoSteps) 747 | } else { 748 | page.page.Mouse.Scroll(0, mainStep, autoSteps) 749 | } 750 | } else { 751 | if isHorizontal { 752 | page.page.Mouse.Scroll(scrollDistance, 0, autoSteps) 753 | } else { 754 | page.page.Mouse.Scroll(0, scrollDistance, autoSteps) 755 | } 756 | } 757 | 758 | return e.ROD().ScrollIntoView() 759 | } 760 | 761 | maxSteps := int(expectedDuration.Seconds() * 2) 762 | minSteps := int(math.Max(3, expectedDuration.Seconds())) 763 | distanceBasedSteps := int(math.Abs(scrollDistance) / 100) 764 | scrollCount := int(math.Max(float64(minSteps), math.Min(float64(maxSteps), float64(distanceBasedSteps)))) 765 | 766 | type scrollStep struct { 767 | distance float64 768 | delay time.Duration 769 | } 770 | 771 | steps := make([]scrollStep, 0, scrollCount) 772 | totalPlannedDelay := time.Duration(0) 773 | totalActualDistance := 0.0 774 | 775 | for i := 0; i < scrollCount; i++ { 776 | progress := float64(i) / float64(scrollCount-1) 777 | 778 | var speedFactor float64 779 | if progress < 0.5 { 780 | speedFactor = 4 * progress * progress 781 | } else { 782 | speedFactor = 1 - math.Pow(-2*progress+2, 2)/2 783 | } 784 | 785 | var randomFactor float64 786 | if progress > 0.2 && progress < 0.8 { 787 | randomFactor = float64(zstring.RandInt(70, 130)) / 100.0 788 | } else { 789 | randomFactor = float64(zstring.RandInt(85, 115)) / 100.0 790 | } 791 | baseStep := scrollDistance / float64(scrollCount) 792 | currentStep := baseStep * speedFactor * randomFactor 793 | totalActualDistance += currentStep 794 | 795 | var baseDelay time.Duration 796 | if progress < 0.2 || progress > 0.8 { 797 | baseDelay = time.Duration(70+rand.Intn(100)) * time.Millisecond 798 | } else { 799 | baseDelay = time.Duration(40+rand.Intn(80)) * time.Millisecond 800 | } 801 | 802 | pauseProb := 0.08 803 | 804 | if expectedDuration > time.Second*10 { 805 | pauseProb = 0.15 806 | } 807 | if progress > 0.3 && progress < 0.7 { 808 | pauseProb += 0.05 809 | } 810 | if rand.Float64() < pauseProb && i < scrollCount-1 { 811 | var pauseTime int 812 | if expectedDuration > time.Second*10 { 813 | pauseTime = 150 + rand.Intn(250) 814 | } else { 815 | pauseTime = 80 + rand.Intn(150) 816 | } 817 | 818 | baseDelay += time.Duration(pauseTime) * time.Millisecond 819 | } 820 | 821 | steps = append(steps, scrollStep{ 822 | distance: currentStep, 823 | delay: baseDelay, 824 | }) 825 | 826 | totalPlannedDelay += baseDelay 827 | } 828 | 829 | finalAdjustment := scrollDistance - totalActualDistance 830 | if len(steps) > 0 { 831 | steps[len(steps)-1].distance += finalAdjustment 832 | } 833 | 834 | reserveRatio := 0.15 + float64(scrollCount)*0.01 835 | if reserveRatio > 0.3 { 836 | reserveRatio = 0.3 837 | } 838 | timeAdjustFactor := float64(expectedDuration) * (1 - reserveRatio) / float64(totalPlannedDelay) 839 | 840 | startTime := time.Now() 841 | 842 | for i, step := range steps { 843 | if time.Now().After(deadline) { 844 | if isHorizontal { 845 | currentPosInfo, _ := page.EvalJS(`() => ({ scrollX: window.scrollX })`) 846 | currentX := float64(currentPosInfo.Get("scrollX").Int()) 847 | remainDist := targetScrollX - currentX 848 | 849 | steps := int(math.Max(5, math.Min(30, math.Abs(remainDist)/20))) 850 | page.page.Mouse.Scroll(remainDist, 0, steps) 851 | } else { 852 | currentPosInfo, _ := page.EvalJS(`() => ({ scrollY: window.scrollY })`) 853 | currentY := float64(currentPosInfo.Get("scrollY").Int()) 854 | remainDist := targetScrollY - currentY 855 | 856 | steps := int(math.Max(5, math.Min(30, math.Abs(remainDist)/20))) 857 | page.page.Mouse.Scroll(0, remainDist, steps) 858 | } 859 | break 860 | } 861 | 862 | stepCount := int(math.Max(1, math.Abs(step.distance)/10)) 863 | 864 | overscrollProb := 0.05 865 | 866 | if expectedDuration > time.Second*8 { 867 | overscrollProb = 0.12 868 | } 869 | 870 | progress := float64(i) / float64(len(steps)-1) 871 | if progress > 0.3 && progress < 0.7 { 872 | overscrollProb += 0.05 873 | } 874 | 875 | if math.Abs(scrollDistance) > 500 { 876 | overscrollProb += 0.05 877 | } 878 | 879 | if rand.Float64() < overscrollProb && i < len(steps)-2 { 880 | overscrollFactor := 1.3 + 0.5*rand.Float64() 881 | overscrollStep := step.distance * overscrollFactor 882 | overscrollStepCount := int(math.Max(1, math.Abs(overscrollStep)/10)) 883 | 884 | var jitterX, jitterY float64 885 | if isHorizontal { 886 | jitterY = (rand.Float64()*2 - 1) * math.Min(10, math.Abs(overscrollStep)*0.05) 887 | page.page.Mouse.Scroll(overscrollStep, jitterY, overscrollStepCount) 888 | } else { 889 | jitterX = (rand.Float64()*2 - 1) * math.Min(10, math.Abs(overscrollStep)*0.05) 890 | page.page.Mouse.Scroll(jitterX, overscrollStep, overscrollStepCount) 891 | } 892 | 893 | time.Sleep(time.Duration(float64(step.delay) * timeAdjustFactor * (0.2 + 0.2*rand.Float64()))) 894 | 895 | backFactor := 0.4 + 0.2*rand.Float64() 896 | backStep := -overscrollStep * backFactor 897 | backStepCount := int(math.Max(1, math.Abs(backStep)/10)) 898 | 899 | if isHorizontal { 900 | jitterY = (rand.Float64()*2 - 1) * math.Min(8, math.Abs(backStep)*0.04) 901 | page.page.Mouse.Scroll(backStep, jitterY, backStepCount) 902 | } else { 903 | jitterX = (rand.Float64()*2 - 1) * math.Min(8, math.Abs(backStep)*0.04) 904 | page.page.Mouse.Scroll(jitterX, backStep, backStepCount) 905 | } 906 | 907 | time.Sleep(time.Duration(float64(step.delay) * timeAdjustFactor * (0.2 + 0.2*rand.Float64()))) 908 | 909 | if isHorizontal { 910 | jitterY = (rand.Float64()*2 - 1) * math.Min(5, math.Abs(step.distance)*0.03) 911 | page.page.Mouse.Scroll(step.distance, jitterY, stepCount) 912 | } else { 913 | jitterX = (rand.Float64()*2 - 1) * math.Min(5, math.Abs(step.distance)*0.03) 914 | page.page.Mouse.Scroll(jitterX, step.distance, stepCount) 915 | } 916 | 917 | time.Sleep(time.Duration(float64(step.delay) * timeAdjustFactor * (0.3 + 0.2*rand.Float64()))) 918 | } else { 919 | var jitterX, jitterY float64 920 | 921 | if isHorizontal { 922 | jitterY = (rand.Float64()*2 - 1) * math.Min(5, math.Abs(step.distance)*0.03) 923 | page.page.Mouse.Scroll(step.distance, jitterY, stepCount) 924 | } else { 925 | jitterX = (rand.Float64()*2 - 1) * math.Min(5, math.Abs(step.distance)*0.03) 926 | page.page.Mouse.Scroll(jitterX, step.distance, stepCount) 927 | } 928 | 929 | if i < len(steps)-1 { 930 | delayJitter := 0.9 + 0.2*rand.Float64() 931 | time.Sleep(time.Duration(float64(step.delay) * timeAdjustFactor * delayJitter)) 932 | } 933 | } 934 | } 935 | 936 | elapsedTime := time.Since(startTime) 937 | 938 | remainingTime := expectedDuration - elapsedTime 939 | if remainingTime > 0 && time.Now().Before(deadline) { 940 | var remainDistCheck float64 941 | if isHorizontal { 942 | viewportCheck, _ := page.EvalJS(`() => ({ scrollX: window.scrollX })`) 943 | currentCheckX := float64(viewportCheck.Get("scrollX").Int()) 944 | remainDistCheck = math.Abs(targetScrollX - currentCheckX) 945 | } else { 946 | viewportCheck, _ := page.EvalJS(`() => ({ scrollY: window.scrollY })`) 947 | currentCheckY := float64(viewportCheck.Get("scrollY").Int()) 948 | remainDistCheck = math.Abs(targetScrollY - currentCheckY) 949 | } 950 | 951 | reserveTimeRatio := 0.05 952 | if remainDistCheck > 50 { 953 | reserveTimeRatio = math.Min(0.1, 0.05+remainDistCheck/1000) 954 | } 955 | 956 | waitTime := remainingTime - time.Duration(float64(expectedDuration)*reserveTimeRatio) 957 | deadlineWait := time.Until(deadline) 958 | if waitTime > deadlineWait { 959 | waitTime = deadlineWait 960 | } 961 | if waitTime > 0 { 962 | time.Sleep(waitTime) 963 | } 964 | } 965 | 966 | if !time.Now().After(deadline) { 967 | var remainingDistance float64 968 | if isHorizontal { 969 | viewportInfo, _ = page.EvalJS(`() => ({ scrollX: window.scrollX })`) 970 | currentX := float64(viewportInfo.Get("scrollX").Int()) 971 | remainingDistance = targetScrollX - currentX 972 | } else { 973 | viewportInfo, _ = page.EvalJS(`() => ({ scrollY: window.scrollY })`) 974 | currentY := float64(viewportInfo.Get("scrollY").Int()) 975 | remainingDistance = targetScrollY - currentY 976 | } 977 | 978 | elapsedTime = time.Since(startTime) 979 | remainingTime = expectedDuration - elapsedTime 980 | 981 | if math.Abs(remainingDistance) > 10 && time.Now().Before(deadline) { 982 | finalStepCount := int(math.Max(1, math.Abs(remainingDistance)/10)) 983 | if remainingTime < 0 { 984 | finalStepCount = 1 985 | } 986 | 987 | if isHorizontal { 988 | page.page.Mouse.Scroll(remainingDistance, 0, finalStepCount) 989 | } else { 990 | page.page.Mouse.Scroll(0, remainingDistance, finalStepCount) 991 | } 992 | } 993 | } 994 | 995 | return e.ROD().ScrollIntoView() 996 | } 997 | --------------------------------------------------------------------------------