Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ The driver is assembled from two npm packages:
- Package name: `patchright` (not `playwright`)
- Downloads `patchright` + `patchright-core` npm packages instead of `playwright-core`
- All `Evaluate` methods pass `isolatedContext: true` by default (avoids Runtime.enable leak)
- Init script injection via route interception
- Init script injection via route interception with `patchrightInitScript` flag
- Stealth API: `NewStealthPage`/`NewStealthContext` auto-patch HeadlessChrome UA
- `PatchHeadlessUA()` helper transforms HeadlessChrome → Chrome, version → major.0.0.0
- Chromium-only (Firefox/WebKit not supported by Patchright)
- Env vars use `PATCHRIGHT_` prefix (not `PLAYWRIGHT_`)
- Config can be passed as struct fields instead of env vars
Expand All @@ -48,15 +50,18 @@ go test ./... -timeout 120s # includes integration tests (downloads driver +

- `run.go` - Driver download, installation, and startup
- `playwright.go` - Main `Patchright` type definition
- `stealth.go` - `NewStealthPage`/`NewStealthContext`, `PatchHeadlessUA` helper
- `connection.go` - JSON pipe communication with the Node.js driver
- `transport.go` - stdio pipe transport
- `transport.go` - stdio pipe transport (uses sync.Once for Close)
- `frame.go` - Frame evaluation methods (with `isolatedContext: true`)
- `worker.go` - Worker evaluation methods (with `isolatedContext: true`)
- `js_handle.go` - JSHandle evaluation methods (with `isolatedContext: true`)
- `page.go` - Page methods including init script route injection
- `route.go` - Route handling
- `page.go` - Page methods, init script route injection with `patchrightInitScript`
- `browser_context.go` - Context methods, context-level route injection
- `route.go` - Route handling, `patchrightInitScript` forwarding
- `request.go` - Request overrides including `PatchrightInitScript`
- `browser_type.go` - Browser launch and context creation
- `generated-*.go` - Auto-generated types from Playwright protocol
- `generated-*.go` - Auto-generated types from Playwright protocol (+ Patchright additions)
- `cmd/patchright/` - CLI tool for driver/browser management

## Key env vars (all optional, struct fields preferred)
Expand Down
65 changes: 56 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# patchright-go

Go library for [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) - a patched version of [Playwright](https://playwright.dev/) that evades bot detection.
Go library for [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) a patched version of [Playwright](https://playwright.dev/) that evades bot detection.

Built on top of [playwright-go](https://github.com/playwright-community/playwright-go), this library downloads and communicates with the Patchright driver (a patched Playwright server) to provide anti-detection browser automation for Go.

Expand All @@ -14,7 +14,7 @@ Built on top of [playwright-go](https://github.com/playwright-community/playwrig
| Closed Shadow DOM | Enables interaction with closed Shadow DOM elements |
| Init script injection | Uses route interception instead of Runtime.enable for init scripts |

**Chromium-only** - Firefox and WebKit are not supported by Patchright.
**Chromium-only** Firefox and WebKit are not supported by Patchright.

## Installation

Expand All @@ -25,7 +25,9 @@ go get github.com/status403com/patchright-go
Install the browser:

```go
patchright.Install()
patchright.Install(&patchright.RunOptions{
Browsers: []string{"chromium"},
})
```

Or via CLI:
Expand All @@ -34,6 +36,12 @@ Or via CLI:
go run github.com/status403com/patchright-go/cmd/patchright@latest install chromium
```

Google Chrome is recommended over Chromium for better anti-detection:

```bash
go run github.com/status403com/patchright-go/cmd/patchright@latest install chrome
```

## Quick start

```go
Expand All @@ -59,7 +67,8 @@ func main() {
}
defer browser.Close()

page, err := browser.NewPage()
// NewStealthPage auto-patches the HeadlessChrome user agent
page, err := browser.NewStealthPage()
if err != nil {
log.Fatal(err)
}
Expand All @@ -71,6 +80,35 @@ func main() {
}
```

## Stealth API

Headless Chromium sends `HeadlessChrome` in the default user agent, which is an instant detection signal. patchright-go provides stealth methods that automatically patch this:

```go
// NewStealthPage — creates a page with a patched user agent
// HeadlessChrome/149.0.7827.55 → Chrome/149.0.0.0
page, err := browser.NewStealthPage()

// NewStealthContext — creates a context with a patched user agent
// All pages created from this context share the patched UA
context, err := browser.NewStealthContext()
page1, _ := context.NewPage()
page2, _ := context.NewPage()

// PatchHeadlessUA — standalone helper to patch any UA string
fixedUA := patchright.PatchHeadlessUA(rawUA)
```

The patched UA applies to `navigator.userAgent` and all HTTP requests (fetch, XHR, navigation).

Alternatively, use Google Chrome which sends the correct UA natively:

```go
browser, err := pw.Chromium.Launch(patchright.BrowserTypeLaunchOptions{
Channel: patchright.String("chrome"),
})
```

## Differences from playwright-go

| Feature | playwright-go | patchright-go |
Expand All @@ -83,6 +121,7 @@ func main() {
| Browsers | Chromium, Firefox, WebKit | Chromium only |
| JS evaluation | Standard context | Isolated context (default) |
| Env prefix | `PLAYWRIGHT_` | `PATCHRIGHT_` |
| Stealth UA | Not available | `NewStealthPage` / `NewStealthContext` |

## Migration from playwright-go

Expand All @@ -91,6 +130,7 @@ func main() {
3. Replace driver type: `playwright.PlaywrightDriver` -> `patchright.PatchrightDriver`
4. Update env vars: `PLAYWRIGHT_*` -> `PATCHRIGHT_*`
5. Remove Firefox/WebKit usage (Chromium only)
6. Use `NewStealthPage` / `NewStealthContext` instead of `NewPage` / `NewContext` for anti-detection

## Configuration

Expand Down Expand Up @@ -121,26 +161,33 @@ Patchright supports running many browser instances from a single Go process:
pw, _ := patchright.Run()
defer pw.Stop()

var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
browser, _ := pw.Chromium.Launch()
defer browser.Close()
page, _ := browser.NewPage()
page, _ := browser.NewStealthPage()
page.Goto("https://example.com")
}()
}
wg.Wait()
```

## API

The API is identical to [playwright-go](https://pkg.go.dev/github.com/mxschmitt/playwright-go) with the type renames listed above. Refer to the playwright-go documentation for the full API reference.
The API is identical to [playwright-go](https://pkg.go.dev/github.com/mxschmitt/playwright-go) with the type renames listed above, plus the stealth methods. Refer to the playwright-go documentation for the full API reference and `docs/llm-guide.md` for a concise cheat sheet.

## Credits

- [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) by Vinyzu
- [playwright-go](https://github.com/playwright-community/playwright-go) by Max Schmitt
- [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) by [Vinyzu](https://github.com/Kaliiiiiiiiii-Vinyzu)
- [patchright-python](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python) by [Vinyzu](https://github.com/Kaliiiiiiiiii-Vinyzu)
- [patchright-nodejs](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-nodejs) by [Vinyzu](https://github.com/Kaliiiiiiiiii-Vinyzu)
- [patchright-dotnet](https://github.com/DevEnterpriseSoftware/patchright-dotnet) by [DevEnterpriseSoftware](https://github.com/DevEnterpriseSoftware)
- [playwright-go](https://github.com/playwright-community/playwright-go) by [Max Schmitt](https://github.com/mxschmitt)
- [Playwright](https://playwright.dev/) by Microsoft

## License

Apache-2.0 (same as playwright-go)
Apache-2.0
4 changes: 4 additions & 0 deletions browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sync"
)

type browserImpl struct {
Expand All @@ -17,6 +18,9 @@ type browserImpl struct {
browserType BrowserType
chromiumTracingPath *string
closeReason *string
stealthUA string
stealthUAOnce sync.Once
stealthUAErr error
}

func (b *browserImpl) BrowserType() BrowserType {
Expand Down
6 changes: 5 additions & 1 deletion browser_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ func (b *browserContextImpl) installInjectRoute() error {
if !b.routeInjecting.CompareAndSwap(false, true) {
return nil
}
return b.Route("**/*", func(route Route) {
err := b.Route("**/*", func(route Route) {
req := route.Request()
if req.ResourceType() == "document" && strings.HasPrefix(req.URL(), "http") {
_ = route.Fallback(RouteFallbackOptions{
Expand All @@ -243,6 +243,10 @@ func (b *browserContextImpl) installInjectRoute() error {
_ = route.Fallback(RouteFallbackOptions{})
}
})
if err != nil {
b.routeInjecting.Store(false)
}
return err
}

func (b *browserContextImpl) AddInitScript(script Script) error {
Expand Down
2 changes: 1 addition & 1 deletion docs/llm-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ for i := 0; i < 10; i++ {
defer wg.Done()
browser, _ := pw.Chromium.Launch()
defer browser.Close()
page, _ := browser.NewPage()
page, _ := browser.NewStealthPage()
page.Goto("https://example.com")
}()
}
Expand Down
9 changes: 3 additions & 6 deletions examples/basic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,18 @@ func main() {
}
defer pw.Stop()

browser, err := pw.Chromium.Launch(patchright.BrowserTypeLaunchOptions{
Headless: patchright.Bool(false),
})
browser, err := pw.Chromium.Launch()
if err != nil {
log.Fatalf("could not launch browser: %v", err)
}
defer browser.Close()

page, err := browser.NewPage()
page, err := browser.NewStealthPage()
if err != nil {
log.Fatalf("could not create page: %v", err)
}

if _, err = page.Goto("https://abrahamjuliot.github.io/creepjs/"); err != nil {
if _, err = page.Goto("https://example.com"); err != nil {
log.Fatalf("could not goto: %v", err)
}

Expand All @@ -37,7 +35,6 @@ func main() {
}
fmt.Printf("Title: %s\n", title)

// Verify navigator.webdriver is false (anti-detection working)
webdriver, err := page.Evaluate("() => navigator.webdriver")
if err != nil {
log.Fatalf("could not evaluate: %v", err)
Expand Down
6 changes: 5 additions & 1 deletion page.go
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,7 @@ func (p *pageImpl) installInjectRoute() error {
if !p.routeInjecting.CompareAndSwap(false, true) {
return nil
}
return p.Route("**/*", func(route Route) {
err := p.Route("**/*", func(route Route) {
req := route.Request()
if req.ResourceType() == "document" && strings.HasPrefix(req.URL(), "http") {
_ = route.Fallback(RouteFallbackOptions{
Expand All @@ -827,6 +827,10 @@ func (p *pageImpl) installInjectRoute() error {
_ = route.Fallback(RouteFallbackOptions{})
}
})
if err != nil {
p.routeInjecting.Store(false)
}
return err
}

func (p *pageImpl) AddInitScript(script Script) error {
Expand Down
61 changes: 32 additions & 29 deletions stealth.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package patchright

import (
"fmt"
"regexp"
"strings"
"sync"
)

var chromeVersionRe = regexp.MustCompile(`(Chrome/)(\d+)\.\d+\.\d+\.\d+`)
Expand Down Expand Up @@ -42,6 +42,7 @@ func (b *browserImpl) NewStealthPage(options ...BrowserNewPageOptions) (Page, er
}
page, err := context.NewPage()
if err != nil {
context.Close()
return nil, err
}
page.(*pageImpl).ownedContext = context
Expand All @@ -67,33 +68,35 @@ func (b *browserImpl) NewStealthContext(options ...BrowserNewContextOptions) (Br
return b.NewContext(opts)
}

var (
patchedUACache string
patchedUACacheMu sync.Mutex
)

func (b *browserImpl) getPatchedUA() (string, error) {
patchedUACacheMu.Lock()
defer patchedUACacheMu.Unlock()

if patchedUACache != "" {
return patchedUACache, nil
}

ctx, err := b.NewContext()
if err != nil {
return "", err
}
page, err := ctx.NewPage()
if err != nil {
ctx.Close()
return "", err
}
rawUA, err := page.Evaluate("() => navigator.userAgent")
ctx.Close()
if err != nil {
return "", err
}
patchedUACache = PatchHeadlessUA(rawUA.(string))
return patchedUACache, nil
b.stealthUAOnce.Do(func() {
ctx, err := b.NewContext()
if err != nil {
b.stealthUAErr = fmt.Errorf("stealth UA: could not create context: %w", err)
return
}
page, err := ctx.NewPage()
if err != nil {
if closeErr := ctx.Close(); closeErr != nil {
logger.Error("stealth UA: could not close context", "error", closeErr)
}
b.stealthUAErr = fmt.Errorf("stealth UA: could not create page: %w", err)
return
}
rawUA, err := page.Evaluate("() => navigator.userAgent")
if closeErr := ctx.Close(); closeErr != nil {
logger.Error("stealth UA: could not close context", "error", closeErr)
}
if err != nil {
b.stealthUAErr = fmt.Errorf("stealth UA: could not evaluate: %w", err)
return
}
ua, ok := rawUA.(string)
if !ok {
b.stealthUAErr = fmt.Errorf("stealth UA: unexpected type %T", rawUA)
return
}
b.stealthUA = PatchHeadlessUA(ua)
})
return b.stealthUA, b.stealthUAErr
}
Loading