From 09c5ffd33b8ba8ff702860e14a5b404ec585a799 Mon Sep 17 00:00:00 2001 From: desperatee Date: Wed, 1 Jul 2026 02:09:01 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20review=20round=202=20=E2=80=94=20per-bro?= =?UTF-8?q?wser=20UA=20cache,=20leak=20fixes,=20docs=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: - CRITICAL: Move UA cache from global package var to per-browser sync.Once on browserImpl. Fixes incorrect cache sharing across browsers with different versions/channels. Eliminates global mutex contention during 50+ browser cold starts. - HIGH: Fix context leak in NewStealthPage when NewPage fails (add context.Close on error path) - HIGH: Guard type assertion in getPatchedUA with comma-ok to prevent panic on unexpected navigator.userAgent type - HIGH: Log ctx.Close errors in getPatchedUA instead of discarding - HIGH: Reset routeInjecting atomic.Bool on Route failure in both page.go and browser_context.go so retries aren't silently skipped Docs: - README: Add stealth API section, update quick start to use NewStealthPage, add Chrome channel docs, update credits - CLAUDE.md: Add stealth.go and new files to file structure, document patchrightInitScript and stealth API - LLM guide: Update concurrent browsers example to use NewStealthPage - Example: Update basic example to use NewStealthPage --- CLAUDE.md | 15 ++++++---- README.md | 65 ++++++++++++++++++++++++++++++++++++------ browser.go | 4 +++ browser_context.go | 6 +++- docs/llm-guide.md | 2 +- examples/basic/main.go | 9 ++---- page.go | 6 +++- stealth.go | 61 ++++++++++++++++++++------------------- 8 files changed, 116 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ff583f3..1a35f16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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) diff --git a/README.md b/README.md index 56e1962..a57ea3b 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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: @@ -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 @@ -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) } @@ -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 | @@ -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 @@ -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 @@ -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 diff --git a/browser.go b/browser.go index f52b37a..0886338 100644 --- a/browser.go +++ b/browser.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sync" ) type browserImpl struct { @@ -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 { diff --git a/browser_context.go b/browser_context.go index bc6f48b..05acc51 100644 --- a/browser_context.go +++ b/browser_context.go @@ -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{ @@ -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 { diff --git a/docs/llm-guide.md b/docs/llm-guide.md index 3cb9ce0..cc38ea0 100644 --- a/docs/llm-guide.md +++ b/docs/llm-guide.md @@ -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") }() } diff --git a/examples/basic/main.go b/examples/basic/main.go index b03934b..057348c 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -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) } @@ -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) diff --git a/page.go b/page.go index ecf11e1..64e8e57 100644 --- a/page.go +++ b/page.go @@ -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{ @@ -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 { diff --git a/stealth.go b/stealth.go index cd9338e..2868641 100644 --- a/stealth.go +++ b/stealth.go @@ -1,9 +1,9 @@ package patchright import ( + "fmt" "regexp" "strings" - "sync" ) var chromeVersionRe = regexp.MustCompile(`(Chrome/)(\d+)\.\d+\.\d+\.\d+`) @@ -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 @@ -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 }