Compare commits
10 Commits
d35d144fa2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b6d864330 | |||
| adefaaef36 | |||
| d89031b20d | |||
| 84e811572b | |||
| 61b68adfd0 | |||
| 0447f1bdbe | |||
| ace6c1e0bf | |||
| 1b95d12890 | |||
| 035151d9fa | |||
| 00ff7ea830 |
@@ -23,8 +23,13 @@ type InteractiveBrowser interface {
|
||||
|
||||
// MouseClick clicks at the given coordinates with the specified button ("left", "middle", "right").
|
||||
MouseClick(x, y float64, button string) error
|
||||
// MouseMove moves the mouse to the given coordinates.
|
||||
MouseMove(x, y float64) error
|
||||
// MouseDown presses the mouse button at the given coordinates without releasing.
|
||||
MouseDown(x, y float64, button string) error
|
||||
// MouseUp releases the mouse button at the given coordinates.
|
||||
MouseUp(x, y float64, button string) error
|
||||
// MouseMove moves the mouse to the given coordinates. An optional steps parameter
|
||||
// controls how many intermediate mousemove events are generated (default 1).
|
||||
MouseMove(x, y float64, steps ...int) error
|
||||
// MouseWheel scrolls by the given delta.
|
||||
MouseWheel(deltaX, deltaY float64) error
|
||||
|
||||
@@ -43,6 +48,16 @@ type InteractiveBrowser interface {
|
||||
// Cookies returns all cookies from the browser context.
|
||||
Cookies() ([]Cookie, error)
|
||||
|
||||
// SetDefaultTimeout sets the default timeout for all Playwright operations
|
||||
// (navigation, clicks, screenshots, cookie extraction, etc.). A value of 0
|
||||
// disables timeouts. By default, Playwright uses a 30-second timeout.
|
||||
//
|
||||
// This is the primary mechanism for preventing hung sessions: callers can
|
||||
// set a timeout so that any Playwright call returns an error instead of
|
||||
// blocking forever if the browser process crashes or the remote server
|
||||
// becomes unresponsive.
|
||||
SetDefaultTimeout(timeout time.Duration)
|
||||
|
||||
// Close tears down the browser.
|
||||
Close() error
|
||||
}
|
||||
@@ -90,6 +105,9 @@ func NewInteractiveBrowser(ctx context.Context, opts ...BrowserOptions) (Interac
|
||||
|
||||
page, err := res.bctx.NewPage()
|
||||
if err != nil {
|
||||
_ = res.bctx.Close()
|
||||
_ = res.browser.Close()
|
||||
_ = res.pw.Stop()
|
||||
ch <- result{nil, fmt.Errorf("failed to create page: %w", err)}
|
||||
return
|
||||
}
|
||||
@@ -107,6 +125,12 @@ func NewInteractiveBrowser(ctx context.Context, opts ...BrowserOptions) (Interac
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
go func() {
|
||||
r := <-ch
|
||||
if r.err == nil && r.ib != nil {
|
||||
_ = r.ib.Close()
|
||||
}
|
||||
}()
|
||||
return nil, ctx.Err()
|
||||
case r := <-ch:
|
||||
return r.ib, r.err
|
||||
@@ -156,8 +180,44 @@ func (ib *interactiveBrowser) MouseClick(x, y float64, button string) error {
|
||||
return ib.page.Mouse().Click(x, y, playwright.MouseClickOptions{Button: btn})
|
||||
}
|
||||
|
||||
func (ib *interactiveBrowser) MouseMove(x, y float64) error {
|
||||
return ib.page.Mouse().Move(x, y)
|
||||
func (ib *interactiveBrowser) MouseDown(x, y float64, button string) error {
|
||||
if err := ib.page.Mouse().Move(x, y); err != nil {
|
||||
return err
|
||||
}
|
||||
var btn *playwright.MouseButton
|
||||
switch button {
|
||||
case "right":
|
||||
btn = playwright.MouseButtonRight
|
||||
case "middle":
|
||||
btn = playwright.MouseButtonMiddle
|
||||
default:
|
||||
btn = playwright.MouseButtonLeft
|
||||
}
|
||||
return ib.page.Mouse().Down(playwright.MouseDownOptions{Button: btn})
|
||||
}
|
||||
|
||||
func (ib *interactiveBrowser) MouseUp(x, y float64, button string) error {
|
||||
if err := ib.page.Mouse().Move(x, y); err != nil {
|
||||
return err
|
||||
}
|
||||
var btn *playwright.MouseButton
|
||||
switch button {
|
||||
case "right":
|
||||
btn = playwright.MouseButtonRight
|
||||
case "middle":
|
||||
btn = playwright.MouseButtonMiddle
|
||||
default:
|
||||
btn = playwright.MouseButtonLeft
|
||||
}
|
||||
return ib.page.Mouse().Up(playwright.MouseUpOptions{Button: btn})
|
||||
}
|
||||
|
||||
func (ib *interactiveBrowser) MouseMove(x, y float64, steps ...int) error {
|
||||
var opts playwright.MouseMoveOptions
|
||||
if len(steps) > 0 && steps[0] > 1 {
|
||||
opts.Steps = playwright.Int(steps[0])
|
||||
}
|
||||
return ib.page.Mouse().Move(x, y, opts)
|
||||
}
|
||||
|
||||
func (ib *interactiveBrowser) MouseWheel(deltaX, deltaY float64) error {
|
||||
@@ -196,6 +256,14 @@ func (ib *interactiveBrowser) Cookies() ([]Cookie, error) {
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
func (ib *interactiveBrowser) SetDefaultTimeout(timeout time.Duration) {
|
||||
ms := float64(timeout.Milliseconds())
|
||||
ib.page.SetDefaultTimeout(ms)
|
||||
ib.page.SetDefaultNavigationTimeout(ms)
|
||||
ib.ctx.SetDefaultTimeout(ms)
|
||||
ib.ctx.SetDefaultNavigationTimeout(ms)
|
||||
}
|
||||
|
||||
func (ib *interactiveBrowser) Close() error {
|
||||
if ib.detached {
|
||||
return nil
|
||||
|
||||
2
node.go
2
node.go
@@ -29,7 +29,7 @@ type node struct {
|
||||
}
|
||||
|
||||
func (n node) Type(input string) error {
|
||||
return n.locator.Type(input)
|
||||
return n.locator.PressSequentially(input)
|
||||
}
|
||||
|
||||
func (n node) Click() error {
|
||||
|
||||
@@ -122,12 +122,17 @@ func playwrightSameSiteToSameSite(s *playwright.SameSiteAttribute) SameSite {
|
||||
}
|
||||
|
||||
func cookieToPlaywrightOptionalCookie(cookie Cookie) playwright.OptionalCookie {
|
||||
expires := float64(cookie.Expires.Unix())
|
||||
if cookie.Expires.IsZero() || expires <= 0 {
|
||||
expires = -1
|
||||
}
|
||||
|
||||
oc := playwright.OptionalCookie{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Domain: playwright.String(cookie.Host),
|
||||
Path: playwright.String(cookie.Path),
|
||||
Expires: playwright.Float(float64(cookie.Expires.Unix())),
|
||||
Expires: playwright.Float(expires),
|
||||
Secure: playwright.Bool(cookie.Secure),
|
||||
HttpOnly: playwright.Bool(cookie.HttpOnly),
|
||||
}
|
||||
@@ -195,6 +200,12 @@ func NewBrowser(ctx context.Context, opts ...BrowserOptions) (Browser, error) {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
go func() {
|
||||
r := <-resultCh
|
||||
if r.err == nil && r.browser != nil {
|
||||
_ = r.browser.Close()
|
||||
}
|
||||
}()
|
||||
return nil, ctx.Err()
|
||||
case result := <-resultCh:
|
||||
return result.browser, result.err
|
||||
@@ -208,11 +219,35 @@ func (b playWrightBrowser) updateCookies(_ context.Context, page playwright.Page
|
||||
return fmt.Errorf("error getting cookies from browser: %w", err)
|
||||
}
|
||||
|
||||
// Build a lookup of existing cookies so we can preserve their security
|
||||
// attributes. Chromium's Cookies() API can lose or normalize Secure,
|
||||
// SameSite, and HttpOnly during the AddCookies → navigate → Cookies()
|
||||
// round-trip, so we only update Value and Expires for cookies that
|
||||
// already exist in the jar.
|
||||
existing, err := b.cookieJar.Get(page.URL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting existing cookies from jar: %w", err)
|
||||
}
|
||||
type cookieKey struct{ Name, Path string }
|
||||
existingMap := make(map[cookieKey]Cookie, len(existing))
|
||||
for _, c := range existing {
|
||||
existingMap[cookieKey{c.Name, c.Path}] = c
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
// TODO: add support for deleting cookies from the jar which are deleted in the browser
|
||||
err = b.cookieJar.Set(playwrightCookieToCookie(cookie))
|
||||
c := playwrightCookieToCookie(cookie)
|
||||
|
||||
if err != nil {
|
||||
if prev, ok := existingMap[cookieKey{c.Name, c.Path}]; ok {
|
||||
// Preserve the original security attributes; only update
|
||||
// Value and Expires which are the fields that legitimately
|
||||
// change during navigation.
|
||||
c.Secure = prev.Secure
|
||||
c.HttpOnly = prev.HttpOnly
|
||||
c.SameSite = prev.SameSite
|
||||
}
|
||||
|
||||
if err = b.cookieJar.Set(c); err != nil {
|
||||
return fmt.Errorf("error setting cookie in cookie jar: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -242,6 +277,7 @@ func (b playWrightBrowser) openPage(_ context.Context, target string, opts OpenP
|
||||
|
||||
resp, err := page.Goto(target, pwOpts)
|
||||
if err != nil {
|
||||
_ = page.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -277,8 +313,8 @@ func (b playWrightBrowser) Open(ctx context.Context, url string, opts OpenPageOp
|
||||
|
||||
func (b playWrightBrowser) Close() error {
|
||||
return errors.Join(
|
||||
b.browser.Close(),
|
||||
b.ctx.Close(),
|
||||
b.browser.Close(),
|
||||
b.pw.Stop(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package extractor
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockInteractiveBrowser implements InteractiveBrowser for testing without Playwright.
|
||||
@@ -13,13 +14,16 @@ func (m mockInteractiveBrowser) GoBack() (string, error) { return
|
||||
func (m mockInteractiveBrowser) GoForward() (string, error) { return "", nil }
|
||||
func (m mockInteractiveBrowser) URL() string { return "" }
|
||||
func (m mockInteractiveBrowser) MouseClick(float64, float64, string) error { return nil }
|
||||
func (m mockInteractiveBrowser) MouseMove(float64, float64) error { return nil }
|
||||
func (m mockInteractiveBrowser) MouseDown(float64, float64, string) error { return nil }
|
||||
func (m mockInteractiveBrowser) MouseUp(float64, float64, string) error { return nil }
|
||||
func (m mockInteractiveBrowser) MouseMove(float64, float64, ...int) error { return nil }
|
||||
func (m mockInteractiveBrowser) MouseWheel(float64, float64) error { return nil }
|
||||
func (m mockInteractiveBrowser) KeyboardType(string) error { return nil }
|
||||
func (m mockInteractiveBrowser) KeyboardPress(string) error { return nil }
|
||||
func (m mockInteractiveBrowser) KeyboardInsertText(string) error { return nil }
|
||||
func (m mockInteractiveBrowser) Screenshot(int) ([]byte, error) { return nil, nil }
|
||||
func (m mockInteractiveBrowser) Cookies() ([]Cookie, error) { return nil, nil }
|
||||
func (m mockInteractiveBrowser) SetDefaultTimeout(time.Duration) {}
|
||||
func (m mockInteractiveBrowser) Close() error { return nil }
|
||||
|
||||
func TestPromoteToInteractive_NonPromotable(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user