From bf65f6ccfa9601be031f52a4463425acb05c6222 Mon Sep 17 00:00:00 2001 From: Star Date: Mon, 21 Jul 2025 00:09:21 +0800 Subject: [PATCH] support use chrome --- chrome.go | 80 ++++ chrome_test.go | 26 ++ chrome_test.js | 199 +++++++++ go.mod | 57 +-- http.go | 33 +- http.ts | 3 + page.go | 1131 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1499 insertions(+), 30 deletions(-) create mode 100644 chrome.go create mode 100644 chrome_test.go create mode 100644 chrome_test.js create mode 100644 page.go diff --git a/chrome.go b/chrome.go new file mode 100644 index 0000000..c08de7f --- /dev/null +++ b/chrome.go @@ -0,0 +1,80 @@ +package http + +import ( + "runtime" + "sync" + + "apigo.cc/gojs" + "apigo.cc/gojs/goja" + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/ssgo/log" + "github.com/ssgo/u" +) + +type Chrome struct { + id string + launcher *launcher.Launcher + browser *rod.Browser +} + +var chromes = map[string]*Chrome{} +var chromesLock sync.Mutex + +func (ch *Chrome) Close() { + if ch.browser != nil { + ch.browser.Close() + ch.browser = nil + } + if ch.launcher != nil { + ch.launcher.Cleanup() + ch.launcher = nil + } + + chromesLock.Lock() + delete(chromes, ch.id) + chromesLock.Unlock() +} + +func CloseAllChrome() { + n := len(chromes) + if n > 0 { + for _, ch := range chromes { + ch.Close() + } + log.DefaultLogger.Info("关闭所有 Chrome 浏览器", "count", n) + } +} + +func StartChrome(showWindow *bool, vm *goja.Runtime) (*Chrome, error) { + logger := gojs.GetLogger(vm) + ch := &Chrome{} + ch.browser = rod.New() + if localBrowserPath, hasLocalBrowser := launcher.LookPath(); hasLocalBrowser { + ch.launcher = launcher.New().Bin(localBrowserPath).Headless(!u.Bool(showWindow)).Set("no-sandbox").Set("disable-gpu").Set("disable-dev-shm-usage").Set("single-process") + switch runtime.GOOS { + case "linux": + ch.launcher.Set("disable-setuid-sandbox") + case "windows": + ch.launcher.Set("disable-features=RendererCodeIntegrity") + } + localChromeURL, err := ch.launcher.Launch() + if err != nil { + ch.Close() + return nil, gojs.Err(err) + } + logger.Info("启动本地 Chrome 浏览器", "url", localChromeURL, "path", localBrowserPath) + ch.browser.ControlURL(localChromeURL) + } + if err := ch.browser.Connect(); err != nil { + ch.Close() + return nil, gojs.Err(err) + } + + ch.id = u.UniqueId() + chromesLock.Lock() + chromes[ch.id] = ch + chromesLock.Unlock() + return ch, nil + +} diff --git a/chrome_test.go b/chrome_test.go new file mode 100644 index 0000000..70bb8c3 --- /dev/null +++ b/chrome_test.go @@ -0,0 +1,26 @@ +package http_test + +import ( + "fmt" + "testing" + + "apigo.cc/gojs" + _ "apigo.cc/gojs/console" + _ "apigo.cc/gojs/file" + _ "apigo.cc/gojs/http" + _ "apigo.cc/gojs/util" + "github.com/ssgo/u" +) + +func TestChrome(t *testing.T) { + gojs.ExportForDev() + r, err := gojs.RunFile("chrome_test.js") + if err != nil { + t.Fatal(u.Red("chrome_test"), u.BRed(err.Error())) + } else if r != true { + t.Fatal(u.Red("chrome_test"), u.BRed(u.JsonP(r))) + } else { + fmt.Println(u.Green("chrome_test"), u.BGreen("test succeess")) + } + gojs.WaitAll() +} diff --git a/chrome_test.js b/chrome_test.js new file mode 100644 index 0000000..0b51d00 --- /dev/null +++ b/chrome_test.js @@ -0,0 +1,199 @@ +import http from 'apigo.cc/gojs/http' +import co from 'apigo.cc/gojs/console' +import u from 'apigo.cc/gojs/util' +import fs from 'apigo.cc/gojs/file' + +let t1, t2 +function printOK(...testResult) { + t2 = u.timestampMS() + co.info('. >> 成功', '耗时[' + (t2 - t1) + ']', !testResult ? '' : testResult.join(' ')) + t1 = t2 +} + +co.log("开始测试...") +t1 = u.timestampMS() + +co.log("启动Chrome浏览器...") +let ch = http.startChrome() +printOK("Chrome启动成功") + +var page + +co.log("打开z20首页...") +page = ch.open("https://z20.cc/") +printOK() + +co.log("设置视口大小...") +page.setViewport(1280, 720) +printOK("1280×720") + +co.log("获取当前URL...") +let url = page.url() +printOK(url) + +co.log("获取页面标题...") +const title = page.title() +printOK(title.substring(0, 15) + (title.length > 15 ? "..." : "")) + +co.log("导航到Apigo.cc首页...") +page.navigate("https://apigo.cc") +printOK() + +co.log("获取当前URL...") +url = page.url() +printOK(url) + +co.log("获取资源列表...") +let res = page.getResList() +printOK(`资源列表(${res.length})获取成功`) + +co.log("获取资源列表(按类型script)...") +res = page.getResListByType("script") +printOK(`资源列表(${res.length})获取成功`) + +co.log("获取资源列表(按MimeType)...") +res = page.getResListByMimeType("text/*") +printOK(`资源列表(${res.length})获取成功`) + +co.log("获取资源内容...") +let content = page.getResContent(res[0].url) +printOK(`资源(${res[0].url})内容(${content.length}/${res[0].size})获取成功`) + +co.log("执行后退操作...") +page.back() +printOK() + +co.log("获取当前URL...") +url = page.url() +printOK(url) + +co.log("执行前进操作...") +page.forward() +printOK() + +co.log("获取当前URL...") +url = page.url() +printOK(url) + +co.log("滚动页面...") +page.scrollTo(0, 500) +printOK() + +co.log("查找Logo...") +const logo = page.find(".logo") +printOK("已找到") + +co.log("检查Logo可见性...") +printOK(logo.isVisible()) + +co.log("获取Logo 尺寸...") +printOK(logo.getAttribute("width"), logo.getAttribute("height")) + +co.log("获取Logo alt...") +printOK(logo.getAttribute("alt")) + +co.log("获取Logo URL...") +printOK(logo.getAttribute("src")) + +co.log("获取Logo位置信息...") +const rect = logo.getBoundingRect() +printOK(`X:${rect.x} Y:${rect.y} W:${rect.width} H:${rect.height}`) + +co.log("导航到Apigo.cc探索页...") +// page = ch.open("https://apigo.cc/explore/repos") +page.navigate("https://apigo.cc/explore/repos") +printOK() + +co.log("获取当前URL...") +url = page.url() +printOK(url) + +co.log("查找搜索框...") +var searchInput = page.findN("input[type=search]") +if (!searchInput) { + return "搜索框不存在" +} +printOK("存在") + +co.log("查找搜索框父...") +const parent = searchInput.parent() +printOK("存在", parent.getAttribute('class')) + +co.log("查找搜索按钮...") +const searchBtn = parent.find("button") +printOK("存在") +printOK("存在", searchBtn.getAttribute('class') == searchBtn.getProperty('className') ? 'class检查通过' : 'class检查失败') + +co.log("查找搜索按钮(findChild)...") +parent.findChild("button") +printOK("成功") + +co.log("查找搜索按钮(findX)...") +parent.findX("./button") +printOK("成功") + +co.log("查找表单...") +const form = searchInput.findParent('form') +printOK("存在", form.getAttribute('id')) + +co.log("检查搜索框可交互性...") +printOK(searchInput.isInteractable()) + +co.log("设置搜索框值...") +searchInput.setValue("ag") +printOK("值已设置") + +co.log("等待跳转搜索结果...") +page.waitPageLoad() +searchInput = page.find("input[type=search]") +printOK("完成", page.url()) + +co.log("获取搜索框值...") +printOK(searchInput.getValue()) + +co.log("查找所有结果链接...") +var list = page.findAll(".flex-list > .flex-item") +printOK(`找到 ${list.length} 个链接`) + +co.log("查找第二个项目的仓库连接...") +const link = list[0].find("a:nth-child(2)") +printOK(link.getAttribute("href")) + +co.log("计算按钮中心位置...") +const linkCenter = link.getCenter() +printOK(`X:${linkCenter.x.toFixed(1)} Y:${linkCenter.y.toFixed(1)}`) + +co.log("移动鼠标到按钮中心...") +page.mouseMoveTo(linkCenter.x, linkCenter.y) +printOK() + +co.log("按下鼠标左键...") +page.mouseClick() +printOK() + +co.log("等待搜索结果稳定...") +page.waitPageLoad() +printOK() + +co.log("获取当前URL...") +url = page.url() +printOK(url) + +co.log("截取页面截图...") +const screenshot1 = page.screenshot() +printOK(`size:${screenshot1.length}`) +// fs.write('./screenshot1.png', screenshot1) + +co.log("截取页面截图(全部)...") +const screenshot2 = page.screenshotFullPage() +printOK(`size:${screenshot2.length}`) +// fs.write('./screenshot2.png', screenshot2) + +co.log("生成PDF...") +const pdf = page.pDF() +printOK(`size:${pdf.length}`) +// fs.write('./page.pdf', pdf) + +co.log("所有测试步骤完成!") + +return true \ No newline at end of file diff --git a/go.mod b/go.mod index 3dd6592..8823333 100644 --- a/go.mod +++ b/go.mod @@ -1,41 +1,48 @@ module apigo.cc/gojs/http -go 1.18 +go 1.23.0 require ( - apigo.cc/gojs v0.0.1 + apigo.cc/gojs v0.0.21 + apigo.cc/gojs/console v0.0.2 + apigo.cc/gojs/file v0.0.4 + apigo.cc/gojs/util v0.0.12 + github.com/go-rod/rod v0.112.8 + github.com/gorilla/websocket v1.5.3 + github.com/ssgo/config v1.7.9 github.com/ssgo/httpclient v1.7.8 - github.com/ssgo/u v1.7.9 + github.com/ssgo/log v1.7.7 + github.com/ssgo/s v1.7.24 + github.com/ssgo/u v1.7.21 ) require ( - apigo.cc/gojs/console v0.0.1 // indirect + github.com/ZZMarquis/gm v1.3.2 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/emmansun/gmsm v0.30.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/gomodule/redigo v1.9.2 // indirect - github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect + github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect + github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect + github.com/obscuren/ecies v0.0.0-20150213224233-7c0f4a9b18d9 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/ssgo/discover v1.7.8 // indirect - github.com/ssgo/redis v1.7.7 // indirect - github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.8.0 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect -) - -require ( - github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect - github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect - github.com/gorilla/websocket v1.5.3 - github.com/ssgo/config v1.7.7 // indirect - github.com/ssgo/log v1.7.7 - github.com/ssgo/s v1.7.13 + github.com/ssgo/discover v1.7.10 // indirect + github.com/ssgo/redis v1.7.8 // indirect github.com/ssgo/standard v1.7.7 // indirect - github.com/ssgo/tool v0.4.27 // indirect - golang.org/x/net v0.30.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect + github.com/ssgo/tool v0.4.29 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect + github.com/ysmood/goob v0.4.0 // indirect + github.com/ysmood/gson v0.7.3 // indirect + github.com/ysmood/leakless v0.9.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/http.go b/http.go index b2fb543..ba2f05e 100644 --- a/http.go +++ b/http.go @@ -12,6 +12,7 @@ import ( "apigo.cc/gojs" "apigo.cc/gojs/goja" "github.com/gorilla/websocket" + "github.com/ssgo/config" "github.com/ssgo/httpclient" "github.com/ssgo/log" "github.com/ssgo/u" @@ -31,8 +32,13 @@ var defaultHttp = &Http{ globalHeaders: make(map[string]string), } -// TODO ws +var conf = struct { + Timeout int + ChromePath string +}{} + func init() { + config.LoadConfig("http", &conf) obj := map[string]any{ "new": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { return newClient("HTTP", argsIn, vm) @@ -51,11 +57,28 @@ func init() { "connect": defaultHttp.Connect, } + obj1 := map[string]any{ + "startChrome": StartChrome, + "closeAllChrome": CloseAllChrome, + } + tsCode := gojs.MakeTSCode(obj1) + + mappedObj := gojs.ToMap(obj1) + for k, v := range mappedObj { + obj[k] = v + } + gojs.Register("apigo.cc/gojs/http", gojs.Module{ - Object: obj, - TsCode: httpTS, - Desc: "", - Example: "", + Object: obj, + // TsCode: httpTS, + TsCodeMaker: func() string { + a := strings.SplitN(tsCode, "export default {", 2) + exportPart1 := "export default {\n" + strings.TrimRight(a[1], "}") + return strings.Replace(httpTS, "export default {", exportPart1, 1) + "\n" + a[0] + }, + Desc: "", + Example: "", + WaitForStop: CloseAllChrome, }) } diff --git a/http.ts b/http.ts index 67f9828..4885f59 100644 --- a/http.ts +++ b/http.ts @@ -26,6 +26,9 @@ function upload(url: string, form: Object, files: Object, headers?: Object): Res function download(filename: string, url: string, callback?: (finished: number, total: number) => void, headers?: Object): Result { return null as any } function connect(url: string, config?: WSConfig): WS { return null as any } +interface Chrome { +} + interface Client { get(url: string, headers?: Object): Result head(url: string, headers?: Object): Result diff --git a/page.go b/page.go new file mode 100644 index 0000000..fbc584e --- /dev/null +++ b/page.go @@ -0,0 +1,1131 @@ +package http + +import ( + "errors" + "fmt" + "io" + "strings" + "time" + + "apigo.cc/gojs" + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/input" + "github.com/go-rod/rod/lib/proto" + "github.com/ssgo/u" +) + +type Page struct { + page *rod.Page + longTimeoutPage *rod.Page + // Res []Resource +} + +type Element struct { + element *rod.Element + page *rod.Page +} + +type Resource struct { + Url string + Type string + Size int + MimeType string + LastModified string + Success bool +} + +type Position struct { + X float64 + Y float64 +} + +type Rect struct{ X, Y, Width, Height float64 } + +func (ch *Chrome) Open(url string) (*Page, error) { + page, err := ch.browser.Page(proto.TargetCreateTarget{URL: url}) + if err != nil { + return nil, gojs.Err(err) + } + + longTimeoutPage := page.Timeout(time.Second * 15) + page = page.Timeout(time.Second * 5) + + pg := &Page{page: page, longTimeoutPage: longTimeoutPage} + // pg := &Page{page: page, longTimeoutPage: longTimeoutPage, Res: []Resource{}} + // proto.NetworkEnable{}.Call(page) + // longTimeoutPage.EachEvent(func(e *proto.NetworkResponseReceived) { + // fmt.Println(u.BMagenta(e.Response.URL), e.Type, e.Response.Status, e.Response.Headers["Content-Type"].Str(), e.Response.Headers["Content-Length"].Int()) + // pg.Res = append(pg.Res, Resource{ + // URL: e.Response.URL, + // Type: string(e.Type), + // Status: e.Response.Status, + // Size: e.Response.Headers["Content-Length"].Int(), + // MimeType: e.Response.Headers["Content-Type"].Str(), + // }) + // }) + + pg.longTimeoutPage.WaitLoad() + return pg, nil +} + +func (pg *Page) Close() error { + // pg.Res = []Resource{} + return gojs.Err(pg.page.Close()) +} + +func (pg *Page) GetResList() ([]Resource, error) { + r, err := proto.PageGetResourceTree{}.Call(pg.page) + if err != nil { + return nil, gojs.Err(err) + } + list := []Resource{} + for _, res := range r.FrameTree.Resources { + list = append(list, Resource{ + Url: res.URL, + Type: string(res.Type), + Size: u.Int(res.ContentSize), + MimeType: res.MIMEType, + LastModified: res.LastModified.String(), + Success: !res.Failed && !res.Canceled, + }) + } + return list, nil +} + +func (pg *Page) GetResListByType(resType string) ([]Resource, error) { + list, err := pg.GetResList() + if err != nil { + return nil, err + } + result := []Resource{} + for _, res := range list { + if strings.EqualFold(res.Type, resType) { + result = append(result, res) + } + } + return result, nil +} + +func (pg *Page) GetResListByMimeType(mimeType string) ([]Resource, error) { + list, err := pg.GetResList() + if err != nil { + return nil, err + } + isMatch := strings.HasSuffix(mimeType, "/*") + mimeType = strings.TrimSuffix(mimeType, "*") + result := []Resource{} + for _, res := range list { + if isMatch { + if strings.HasPrefix(res.MimeType, mimeType) { + result = append(result, res) + } + } else { + if strings.EqualFold(res.MimeType, mimeType) { + result = append(result, res) + } + } + } + return result, nil +} + +func (pg *Page) GetResContent(url string) ([]byte, error) { + return pg.longTimeoutPage.GetResource(url) +} + +func (pg *Page) Find(selector string) (*Element, error) { + el, err := pg.page.Element(selector) + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: el, page: pg.page}, nil +} + +func (pg *Page) FindN(selector string) *Element { + els, err := pg.FindAll(selector) + if err != nil { + return nil + } + if len(els) == 0 { + return nil + } + return els[0] +} + +func (pg *Page) FindAll(selector string) ([]*Element, error) { + elements, err := pg.page.Elements(selector) + if err != nil { + return nil, gojs.Err(err) + } + result := make([]*Element, len(elements)) + for i, el := range elements { + result[i] = &Element{element: el, page: pg.page} + } + return result, nil +} + +// FindX 通过XPath查找元素 +func (pg *Page) FindX(xpath string) (*Element, error) { + el, err := pg.page.ElementX(xpath) + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: el, page: pg.page}, nil +} + +func (pg *Page) FindXN(selector string) *Element { + el, err := pg.FindX(selector) + if err != nil { + return nil + } + return el +} + +// FindsX 通过XPath查找多个元素 +func (pg *Page) FindAllX(xpath string) ([]*Element, error) { + elements, err := pg.page.ElementsX(xpath) + if err != nil { + return nil, gojs.Err(err) + } + result := make([]*Element, len(elements)) + for i, el := range elements { + result[i] = &Element{element: el, page: pg.page} + } + return result, nil +} + +func (pg *Page) Navigate(url string) error { + err := pg.longTimeoutPage.Navigate(url) + if err != nil { + return gojs.Err(err) + } + pg.longTimeoutPage.WaitLoad() + return nil +} + +// WaitLoad 等待页面加载完成 +func (pg *Page) WaitPageLoad() error { + return gojs.Err(pg.longTimeoutPage.WaitLoad()) +} + +// WaitIdle 等待页面空闲(无网络请求和JS执行) +func (pg *Page) WaitIdle(ms *int) error { + if ms == nil { + ms = u.IntPtr(1000) + } + return gojs.Err(pg.page.WaitIdle(time.Millisecond * time.Duration(*ms))) +} + +// WaitStable 等待页面DOM稳定 +// func (pg *Page) WaitStable(ms *int) error { +// if ms == nil { +// ms = u.IntPtr(100) +// } +// return gojs.Err(pg.page.WaitStable(time.Millisecond * time.Duration(*ms))) +// } + +// Screenshot 截图 +func (pg *Page) ScreenshotFullPage() ([]byte, error) { + buf, err := pg.longTimeoutPage.Screenshot(true, nil) + return buf, gojs.Err(err) +} + +func (pg *Page) Screenshot() ([]byte, error) { + buf, err := pg.longTimeoutPage.Screenshot(false, nil) + return buf, gojs.Err(err) +} + +// PDF 生成页面PDF +func (pg *Page) PDF() ([]byte, error) { + r, err := pg.longTimeoutPage.PDF(&proto.PagePrintToPDF{}) + if err != nil { + return nil, gojs.Err(err) + } + bin, err := io.ReadAll(r) + if err != nil { + return nil, gojs.Err(err) + } + + return bin, nil +} + +func (pg *Page) Title() (string, error) { + info, err := pg.page.Info() + if err != nil { + return "", gojs.Err(err) + } + return info.Title, nil +} + +func (pg *Page) Html() (string, error) { + html, err := pg.page.HTML() + return html, gojs.Err(err) +} + +func (pg *Page) Url() (string, error) { + info, err := pg.page.Info() + if err != nil { + return "", gojs.Err(err) + } + return info.URL, nil +} + +func (pg *Page) Eval(js string) (interface{}, error) { + r, err := pg.page.Eval(js) + if err != nil { + return nil, gojs.Err(err) + } + return r.Value.Val(), nil +} + +func (pg *Page) MouseMoveTo(x, y float64) error { + return gojs.Err(pg.page.Mouse.MoveTo(proto.Point{X: x, Y: y})) +} + +func (pg *Page) MouseClick() error { + return gojs.Err(pg.page.Mouse.Click(proto.InputMouseButtonLeft, 1)) +} + +func (pg *Page) MouseRightClick() error { + return gojs.Err(pg.page.Mouse.Click(proto.InputMouseButtonRight, 1)) +} + +func (pg *Page) MouseMiddleClick() error { + return gojs.Err(pg.page.Mouse.Click(proto.InputMouseButtonMiddle, 1)) +} + +func (pg *Page) MouseDown() error { + return gojs.Err(pg.page.Mouse.Down(proto.InputMouseButtonLeft, 1)) +} + +func (pg *Page) MouseUp() error { + return gojs.Err(pg.page.Mouse.Up(proto.InputMouseButtonLeft, 1)) +} + +func (pg *Page) SetCookies(cookies map[string]string) error { + cks := []*proto.NetworkCookieParam{} + for k, v := range cookies { + cks = append(cks, &proto.NetworkCookieParam{ + Name: k, + Value: v, + }) + } + err := pg.page.SetCookies(cks) + if err != nil { + return gojs.Err(err) + } + return nil +} + +func (pg *Page) GetCookies() (map[string]string, error) { + cookies, err := pg.page.Cookies(nil) + if err != nil { + return nil, gojs.Err(err) + } + + result := make(map[string]string) + for _, c := range cookies { + result[c.Name] = c.Value + } + return result, nil +} + +// SetViewport 设置视口大小 +func (pg *Page) SetViewport(width, height int) error { + return gojs.Err(pg.page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ + Width: width, + Height: height, + DeviceScaleFactor: 0, + Mobile: false, + })) +} + +func (pg *Page) SetPhoneViewport(width, height int) error { + return gojs.Err(pg.page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ + Width: width, + Height: height, + DeviceScaleFactor: 0, + Mobile: true, + })) +} + +// ScrollTo 滚动到指定位置 +func (pg *Page) ScrollTo(x, y float64) error { + _, err := pg.page.Eval(`(x, y) => window.scrollTo(x, y)`, x, y) + return gojs.Err(err) +} + +// Frame 获取iframe内容 +func (pg *Page) Frame(selector string) (*Page, error) { + el, err := pg.page.Element(selector) + if err != nil { + return nil, gojs.Err(err) + } + p, err := el.Frame() + if err != nil { + return nil, gojs.Err(err) + } + return &Page{page: p}, nil +} + +// Page级 +func (pg *Page) Press(key string) error { + return gojs.Err(pg.page.Keyboard.Press(input.Key(key[0]))) +} + +func (pg *Page) SetUserAgent(ua string) error { + return gojs.Err(pg.page.SetUserAgent(&proto.NetworkSetUserAgentOverride{ + UserAgent: ua, + })) +} + +func (pg *Page) SetUserAgentInfo(ua string, platform string, acceptLanguage string) error { + return gojs.Err(pg.page.SetUserAgent(&proto.NetworkSetUserAgentOverride{ + UserAgent: ua, + Platform: platform, + AcceptLanguage: acceptLanguage, + })) +} + +func (pg *Page) Back() error { + err := pg.page.NavigateBack() + if err != nil { + return gojs.Err(err) + } + pg.longTimeoutPage.WaitIdle(time.Second) + return nil +} + +func (pg *Page) Forward() error { + err := pg.page.NavigateForward() + if err != nil { + return gojs.Err(err) + } + pg.longTimeoutPage.WaitIdle(time.Second) + return nil +} + +// ================== 对话框控制 ================== + +// HandleDialog 设置弹框处理器 +// 示例:pg.HandleDialog(true, "") 接受所有弹框 +func (pg *Page) HandleDialog(accept bool, promptText string) error { + wait, handle := pg.longTimeoutPage.HandleDialog() + wait() + return gojs.Err(handle(&proto.PageHandleJavaScriptDialog{ + Accept: accept, + PromptText: promptText, + })) +} + +// AutoAcceptAlerts 自动接受所有弹框 +func (pg *Page) AccepDialog() error { + return gojs.Err(pg.HandleDialog(true, "")) +} + +// AutoDismissAlerts 自动取消所有弹框 +func (pg *Page) DismissDialog() error { + return gojs.Err(pg.HandleDialog(false, "")) +} + +func (pg *Page) SetPrompt(text string) error { + return gojs.Err(pg.HandleDialog(true, text)) +} + +// ================== Element 方法 ================== + +func (el *Element) Click() error { + return gojs.Err(el.element.Click(proto.InputMouseButtonLeft, 1)) +} + +func (el *Element) Input(text string) error { + return gojs.Err(el.element.Input(text)) +} + +func (el *Element) Text() (string, error) { + text, err := el.element.Text() + return text, gojs.Err(err) +} + +func (el *Element) InnerHTML() (string, error) { + r, err := el.element.Eval(`el => el.innerHTML`) + if err != nil { + return "", gojs.Err(err) + } + return r.Value.String(), nil +} + +func (el *Element) OuterHTML() (string, error) { + html, err := el.element.HTML() + return html, gojs.Err(err) +} + +func (el *Element) SetInnerHTML(html string) error { + _, err := el.element.Eval(`(el, html) => el.innerHTML = html`, html) + return gojs.Err(err) +} + +func (el *Element) SetText(text string) error { + _, err := el.element.Eval(`(el, text) => el.innerText = text`, text) + return gojs.Err(err) +} + +// ---------- + +func (el *Element) WaitVisible() error { + return gojs.Err(el.element.WaitVisible()) +} + +func (el *Element) WaitInvisible() error { + return gojs.Err(el.element.WaitInvisible()) +} + +func (el *Element) WaitEnabled() error { + return gojs.Err(el.element.WaitEnabled()) +} + +func (el *Element) Focus() error { + return gojs.Err(el.element.Focus()) +} + +func (el *Element) ScrollIntoView() error { + return gojs.Err(el.element.ScrollIntoView()) +} + +func (el *Element) UploadFile(filePaths []string) error { + return gojs.Err(el.element.SetFiles(filePaths)) +} + +func (el *Element) Hover() error { + return gojs.Err(el.element.Hover()) +} + +// Select 选择下拉框的选项(单选或多选) +func (el *Element) Select(values ...string) error { + if len(values) == 0 { + return nil + } + return gojs.Err(el.element.Select(values, true, rod.SelectorTypeText)) +} + +// GetSelectedValues 获取当前选中的值(单选返回字符串,多选返回切片) +func (el *Element) GetChecked() ([]string, error) { + selected, err := el.element.Elements(":checked") + if err != nil { + return nil, gojs.Err(err) + } + values := make([]string, len(selected)) + for i, opt := range selected { + val, err := opt.Attribute("value") + if err != nil { + return nil, gojs.Err(err) + } + values[i] = u.String(val) + } + return values, nil +} + +// GetAllOptions 获取所有选项(值和文本) +func (el *Element) GetOptions() ([]map[string]string, error) { + options, err := el.element.Elements("option") + if err != nil { + return nil, gojs.Err(err) + } + result := make([]map[string]string, len(options)) + for i, opt := range options { + val, err := opt.Attribute("value") + if err != nil { + return nil, gojs.Err(err) + } + text, err := opt.Text() + if err != nil { + return nil, gojs.Err(err) + } + result[i] = map[string]string{ + "value": u.String(val), + "text": text, + } + } + return result, nil +} + +// RadioGroupValue 获取单选按钮组的当前值 +func (el *Element) RadioGroupValue() (string, error) { + name, err := el.GetAttribute("name") + if err != nil { + return "", gojs.Err(err) + } + if name == nil { + return "", nil + } + + checked, err := el.page.ElementX(fmt.Sprintf("input[type='radio'][name='%s']:checked", *name)) + if err != nil { + return "", gojs.Err(err) + } + if checked == nil { + return "", nil + } + val, err := checked.Attribute("value") + if err != nil { + return "", gojs.Err(err) + } + return u.String(val), nil +} + +// SetRadioGroupValue 设置单选按钮组的值 +func (el *Element) SetRadioGroupValue(value string) error { + name, err := el.GetAttribute("name") + if err != nil { + return gojs.Err(err) + } + if name == nil { + return nil + } + + radio, err := el.page.ElementX(fmt.Sprintf("input[type='radio'][name='%s'][value='%s']", *name, value)) + if err != nil { + return gojs.Err(err) + } + return gojs.Err(radio.Click(proto.InputMouseButtonLeft, 1)) +} + +// SelectByValue 通过值选择选项 +func (el *Element) SelectByValue(value string) error { + opt, err := el.element.ElementX(fmt.Sprintf(".//option[@value='%s']", value)) + if err != nil { + return gojs.Err(err) + } + return gojs.Err(opt.Click(proto.InputMouseButtonLeft, 1)) +} + +// SelectByText 通过文本选择选项 +func (el *Element) SelectByText(text string) error { + opt, err := el.element.ElementX(fmt.Sprintf(".//option[normalize-space()='%s']", text)) + if err != nil { + return gojs.Err(err) + } + return gojs.Err(opt.Click(proto.InputMouseButtonLeft, 1)) +} + +// SetRadioValue 设置单选框值 +func (el *Element) SetRadioValue(value string) error { + radio, err := el.element.ElementX(fmt.Sprintf(".//input[@type='radio'][@value='%s']", value)) + if err != nil { + return gojs.Err(err) + } + return gojs.Err(radio.Click(proto.InputMouseButtonLeft, 1)) +} + +// GetForm 获取当前元素所属的表单 +func (el *Element) GetForm() (*Element, error) { + form, err := el.element.ElementX("ancestor::form") + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: form, page: el.page}, nil +} + +// GetFormData 获取表单数据 +func (el *Element) GetFormData() (map[string]string, error) { + r, err := el.element.Eval(`form => { + const formData = new FormData(form); + return Object.fromEntries(formData.entries()); + }`) + if err != nil { + return nil, gojs.Err(err) + } + data := r.Value.Map() + result := make(map[string]string) + for k, v := range data { + result[k] = v.String() + } + return result, nil +} + +// SetValue 设置表单元素的值(input/textarea) +func (el *Element) SetValue(value string) error { + // _, err := el.element.Eval(`(el, v) => { el.value = v }`, value) + err := el.element.Input(value) + if err == nil { + return nil + } + el.WaitStable(nil) + return nil +} + +// GetValue 获取表单元素的值 +func (el *Element) GetValue() (string, error) { + prop, err := el.element.Property("value") + if err != nil { + return "", gojs.Err(err) + } + return prop.Str(), nil +} + +// SetChecked 设置复选框/单选框的选中状态 +func (el *Element) SetChecked(checked bool) error { + current, err := el.IsChecked() + if err != nil { + return gojs.Err(err) + } + if current != checked { + return gojs.Err(el.element.Click(proto.InputMouseButtonLeft, 1)) + } + return nil +} + +// IsChecked 获取复选框/单选框的选中状态 +func (el *Element) IsChecked() (bool, error) { + prop, err := el.element.Property("checked") + if err != nil { + return false, gojs.Err(err) + } + return prop.Bool(), nil +} + +// IsDisabled 判断元素是否被禁用 +func (el *Element) IsDisabled() (bool, error) { + prop, err := el.element.Property("disabled") + if err != nil { + return false, gojs.Err(err) + } + return prop.Bool(), nil +} + +const maxParentDepth = 100 // 防止无限递归 +func findParentRecursive(el *rod.Element, selector string, depth int) (*rod.Element, error) { + if depth > maxParentDepth { + return nil, gojs.Err(fmt.Errorf("max recursion depth (%d) reached", maxParentDepth)) + } + + // 获取直接父元素 + parent, err := el.Parent() + if err != nil || parent == nil { + return nil, gojs.Err(err) + } + + // 检查是否匹配选择器 + matches, err := parent.Matches(selector) + if err != nil { + return nil, gojs.Err(err) + } + + if matches { + return parent, nil + } + + // 继续向上递归查找 + return findParentRecursive(parent, selector, depth+1) +} + +// FindParent 向上查找匹配选择器的父元素 +func (el *Element) FindParent(selector string) (*Element, error) { + parent, err := findParentRecursive(el.element, selector, 0) + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: parent, page: el.page}, nil +} + +func (el *Element) FindParentN(selector string) *Element { + el, err := el.FindParent(selector) + if err != nil { + return nil + } + return el +} + +// FindParent 向上查找匹配选择器的父元素 +func (el *Element) FindParentX(selector string) (*Element, error) { + parent, err := el.element.ElementX(fmt.Sprintf("ancestor::%s", selector)) + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: parent, page: el.page}, nil +} + +func (el *Element) FindParentXN(selector string) *Element { + el, err := el.FindParentX(selector) + if err != nil { + return nil + } + return el +} + +// Parent 获取直接父元素 +func (el *Element) Parent() (*Element, error) { + parent, err := el.element.ElementX("..") + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: parent, page: el.page}, nil +} + +// Children 获取所有直接子元素 +func (el *Element) Children() ([]*Element, error) { + children, err := el.element.ElementsX("./*") + if err != nil { + return nil, gojs.Err(err) + } + result := make([]*Element, len(children)) + for i, c := range children { + result[i] = &Element{element: c, page: el.page} + } + return result, nil +} + +// Find 在当前元素下查找匹配选择器的第一个元素 +func (el *Element) FindChild(selector string) (*Element, error) { + elem, err := el.element.Element(":scope > " + selector) + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: elem, page: el.page}, nil +} + +func (el *Element) FindChildN(selector string) *Element { + el, err := el.FindChild(selector) + if err != nil { + return nil + } + return el +} + +// FindAll 在当前元素下查找所有匹配选择器的元素 +func (el *Element) FindChildren(selector string) ([]*Element, error) { + elements, err := el.element.Elements(":scope > " + selector) + if err != nil { + return nil, gojs.Err(err) + } + result := make([]*Element, len(elements)) + for i, e := range elements { + result[i] = &Element{element: e, page: el.page} + } + return result, nil +} + +// Find 在当前元素下查找匹配选择器的第一个元素 +func (el *Element) Find(selector string) (*Element, error) { + elem, err := el.element.Element(selector) + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: elem, page: el.page}, nil +} + +func (el *Element) FindN(selector string) *Element { + el, err := el.Find(selector) + if err != nil { + return nil + } + return el +} + +// FindAll 在当前元素下查找所有匹配选择器的元素 +func (el *Element) FindAll(selector string) ([]*Element, error) { + elements, err := el.element.Elements(selector) + if err != nil { + return nil, gojs.Err(err) + } + result := make([]*Element, len(elements)) + for i, e := range elements { + result[i] = &Element{element: e, page: el.page} + } + return result, nil +} + +// Find 在当前元素下查找匹配选择器的第一个元素 +func (el *Element) FindX(selector string) (*Element, error) { + elem, err := el.element.ElementX(selector) + if err != nil { + return nil, gojs.Err(err) + } + return &Element{element: elem, page: el.page}, nil +} + +func (el *Element) FindXN(selector string) *Element { + el, err := el.FindX(selector) + if err != nil { + return nil + } + return el +} + +// FindAll 在当前元素下查找所有匹配选择器的元素 +func (el *Element) FindAllX(selector string) ([]*Element, error) { + elements, err := el.element.ElementsX(selector) + if err != nil { + return nil, gojs.Err(err) + } + result := make([]*Element, len(elements)) + for i, e := range elements { + result[i] = &Element{element: e, page: el.page} + } + return result, nil +} + +// Submit 提交表单(必须在FORM元素上调用) +func (el *Element) Submit() error { + tagName, err := el.element.Eval("el => el.tagName") + if err != nil { + return gojs.Err(err) + } + if !strings.EqualFold(tagName.Value.String(), "FORM") { + return gojs.Err(fmt.Errorf("Submit can only be called on a FORM element")) + } + _, err = el.element.Eval("form => form.submit()") + return gojs.Err(err) +} + +// Check 勾选复选框 +func (el *Element) Check() error { + checked, err := el.IsChecked() + if err != nil { + return gojs.Err(err) + } + if !checked { + return gojs.Err(el.element.Click(proto.InputMouseButtonLeft, 1)) + } + return nil +} + +// Uncheck 取消勾选 +func (el *Element) Uncheck() error { + checked, err := el.IsChecked() + if err != nil { + return gojs.Err(err) + } + if checked { + return gojs.Err(el.element.Click(proto.InputMouseButtonLeft, 1)) + } + return nil +} + +func (el *Element) GetBoundingRect() (*Rect, error) { + shape, err := el.element.Shape() + if err != nil { + return nil, gojs.Err(err) + } + + if box := shape.Box(); box != nil { + return &Rect{X: box.X, Y: box.Y, Width: box.Width, Height: box.Height}, nil + } + + return &Rect{}, nil +} + +func (el *Element) GetCenter() (*Position, error) { + rect, err := el.GetBoundingRect() + if err != nil { + return nil, gojs.Err(err) + } + return &Position{X: rect.X + rect.Width/2, Y: rect.Y + rect.Height/2}, nil +} + +// GetName 获取元素名称(如) +func (el *Element) GetName() (*string, error) { + return el.GetAttribute("name") +} + +// GetID 获取元素ID +func (el *Element) GetID() (*string, error) { + return el.GetAttribute("id") +} + +// GetType 获取元素类型(如"text"/"checkbox"等) +func (el *Element) GetType() (*string, error) { + return el.GetAttribute("type") +} + +func (el *Element) AddClass(className string) error { + _, err := el.element.Eval(`(el, className) => { + if (!el) return; + if (el.classList) { + el.classList.add(className); + } else if (el.className) { + const classes = el.className.split(' '); + if (!classes.includes(className)) { + classes.push(className); + el.className = classes.join(' ').trim(); + } + } + }`, className) + return gojs.Err(err) +} + +func (el *Element) GetClass() ([]string, error) { + r, err := el.element.Property("className") + if err != nil { + return nil, gojs.Err(err) + } + return u.SplitWithoutNone(r.Str(), " "), nil +} + +func (el *Element) RemoveClass(className string) error { + _, err := el.element.Eval(`(el, className) => { + if (!el) return; + if (el.classList) { + el.classList.remove(className); + } else if (el.className) { + const classes = el.className.split(' '); + const index = classes.indexOf(className); + if (index !== -1) { + classes.splice(index, 1); + el.className = classes.join(' ').trim(); + } + } + }`, className) + return gojs.Err(err) +} + +// GetStyle 获取指定CSS样式值 +func (el *Element) GetStyle(property string) (string, error) { + r, err := el.element.Eval(`(el, prop) => + window.getComputedStyle(el).getPropertyValue(prop)`, + property) + if err != nil { + return "", gojs.Err(err) + } + return r.Value.Str(), nil +} + +// SetStyle 设置CSS样式 +func (el *Element) SetStyle(property, value string) error { + _, err := el.element.Eval(`(el, prop, val) => el.style.setProperty(prop, val)`, property, value) + return gojs.Err(err) +} + +// SetAttribute 设置元素属性值 +// name: 属性名称 value: 属性值 +func (el *Element) SetAttribute(name, value string) error { + _, err := el.element.Eval(`(e, n, v) => e.setAttribute(n, v)`, name, value) + return gojs.Err(err) +} + +// HasAttribute 检查属性是否存在 +func (el *Element) HasAttribute(name string) (bool, error) { + r, err := el.element.Eval(`(el, name) => el.hasAttribute(name)`, name) + if err != nil { + return false, gojs.Err(err) + } + return r.Value.Bool(), nil +} + +// GetAttribute 获取元素属性值 +// name: 属性名称 +// 返回值: 属性值字符串,若不存在则返回空字符串 +func (el *Element) GetAttribute(name string) (*string, error) { + val, err := el.element.Attribute(name) + return val, gojs.Err(err) +} + +// RemoveAttribute 移除元素属性 +// name: 要移除的属性名称 +func (el *Element) RemoveAttribute(name string) error { + _, err := el.element.Eval(`(e, n) => e.removeAttribute(n)`, name) + return gojs.Err(err) +} + +// SetProperty 设置 JavaScript 属性 +// name: 属性名称 value: 属性值(可以是任意类型) +func (el *Element) SetProperty(name string, value interface{}) error { + _, err := el.element.Eval(`(el, n, v) => el[n] = v`, name, value) + return gojs.Err(err) +} + +// GetProperty 获取 JavaScript 属性值 +// name: 属性名称 +// 返回值: 属性值(interface{} 类型) +func (el *Element) GetProperty(name string) (interface{}, error) { + prop, err := el.element.Property(name) + if err != nil { + return nil, gojs.Err(err) + } + return prop.Val(), nil +} + +// HasProperty 检查属性是否存在 +func (el *Element) HasProperty(name string) (bool, error) { + r, err := el.element.Eval(`(el, name) => el.hasOwnProperty(name)`, name) + if err != nil { + return false, gojs.Err(err) + } + return r.Value.Bool(), nil +} + +func (el *Element) RemoveProperty(name string) error { + _, err := el.element.Eval(`(el, name) => delete el[name]`, name) + return gojs.Err(err) +} + +// IsInteractable 元素是否可交互 +func (el *Element) IsInteractable() (bool, error) { + _, err := el.element.Interactable() + if errors.Is(err, &rod.ErrNotInteractable{}) { + return false, nil + } + if err != nil { + return false, gojs.Err(err) + } + return true, nil +} + +func (el *Element) WaitStable(ms *int) error { + if ms == nil { + ms = u.IntPtr(100) + } + return gojs.Err(el.element.Timeout(time.Second).WaitStable(time.Millisecond * time.Duration(*ms))) +} + +// 元素级 +func (el *Element) Press(key string) error { + if err := el.element.Focus(); err != nil { + return gojs.Err(err) + } + return gojs.Err(el.element.Page().Keyboard.Press(input.Key(key[0]))) +} + +// IsVisible 判断元素是否可见 +func (el *Element) IsVisible() (bool, error) { + visible, err := el.element.Visible() + if err != nil { + return false, gojs.Err(err) + } + return visible, nil +} + +// GetPosition 获取元素位置(中心点) +func (el *Element) GetPosition() (*Position, error) { + shape, err := el.element.Shape() + if err != nil { + return nil, gojs.Err(err) + } + rect := shape.Box() + return &Position{ + X: rect.X + rect.Width/2, + Y: rect.Y + rect.Height/2, + }, nil +} + +// GetComputedStyle 获取元素所有计算样式 +func (el *Element) GetComputedStyle() (map[string]string, error) { + r, err := el.element.Eval(`(el) => { + const styles = window.getComputedStyle(el); + const result = {}; + for (let i = 0; i < styles.length; i++) { + const prop = styles[i]; + result[prop] = styles.getPropertyValue(prop); + } + return result; + }`) + if err != nil { + return nil, gojs.Err(err) + } + + style := r.Value.Map() + result := make(map[string]string) + for key, value := range style { + result[key] = value.String() + } + return result, nil +}