diff --git a/baidu/search.go b/baidu/search.go index 54e0f2d..6db5537 100644 --- a/baidu/search.go +++ b/baidu/search.go @@ -74,6 +74,7 @@ func (baid *Baidu) isTimeout(page *rod.Page) bool { // It may return core.ErrCaptcha or core.ErrSearchTimeout. func (baid *Baidu) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) { ctx = core.WithEngine(core.EnsureContext(ctx), baid.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *baid scoped.logger = baid.logger.WithRequest(ctx) @@ -177,6 +178,7 @@ func (baid *Baidu) Search(ctx context.Context, query core.Query) (results []core // results. It may return core.ErrCaptcha or core.ErrSearchTimeout. func (baid *Baidu) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) { ctx = core.WithEngine(core.EnsureContext(ctx), baid.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *baid scoped.logger = baid.logger.WithRequest(ctx) diff --git a/bing/search.go b/bing/search.go index f0acdb4..392b636 100644 --- a/bing/search.go +++ b/bing/search.go @@ -98,6 +98,7 @@ func (bing *Bing) acceptCookies(ctx context.Context, page *rod.Page) error { // It may return core.ErrCaptcha or core.ErrSearchTimeout. func (bing *Bing) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) { ctx = core.WithEngine(core.EnsureContext(ctx), bing.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *bing scoped.logger = bing.logger.WithRequest(ctx) @@ -274,6 +275,7 @@ type BingImageData struct { // results. It may return core.ErrCaptcha or core.ErrSearchTimeout. func (bing *Bing) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) { ctx = core.WithEngine(core.EnsureContext(ctx), bing.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *bing scoped.logger = bing.logger.WithRequest(ctx) diff --git a/cmd/root.go b/cmd/root.go index fcfeefc..9497605 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/karust/openserp/core" + browserprofile "github.com/karust/openserp/core/browser" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -14,7 +15,7 @@ import ( ) const ( - version = "0.6.5" + version = "0.6.6" defaultConfigFilename = "config" envPrefix = "OPENSERP" ) @@ -53,10 +54,10 @@ type ServerConfig struct { type AppConfig struct { Timeout int `mapstructure:"timeout"` BrowserPath string `mapstructure:"browser_path"` + ProfilesJSON string `mapstructure:"profiles"` IsBrowserHead bool `mapstructure:"head"` IsLeaveHead bool `mapstructure:"leave_head"` IsLeakless bool `mapstructure:"leakless"` - IsStealth bool `mapstructure:"stealth"` DebugEndpoints bool `mapstructure:"debug_endpoints"` LogFormat string `mapstructure:"log_format"` } @@ -102,6 +103,7 @@ var flagToConfigKey = map[string]string{ "timeout": "app.timeout", "config": "server.config_path", "browser-path": "app.browser_path", + "profiles-json": "app.profiles", "verbose": "server.verbose", "debug": "server.debug", "head": "app.head", @@ -110,7 +112,6 @@ var flagToConfigKey = map[string]string{ "leave": "app.leave_head", "2captcha_key": "2captcha.apikey", "proxy": "proxies.global", - "stealth": "app.stealth", "debug-endpoints": "app.debug_endpoints", "insecure": "server.insecure", "cache_ttl": "cache.ttl_seconds", @@ -134,6 +135,9 @@ var RootCmd = &cobra.Command{ if err != nil { return err } + if err := browserprofile.LoadProfilesFromJSON(config.App.ProfilesJSON); err != nil { + return fmt.Errorf("load app.profiles: %w", err) + } logFormat, err := core.NormalizeLogFormat(config.App.LogFormat) if err != nil { @@ -316,10 +320,10 @@ func setConfigDefaults(v *viper.Viper) { v.SetDefault("app.timeout", 30) v.SetDefault("app.browser_path", "") + v.SetDefault("app.profiles", "") v.SetDefault("app.head", false) v.SetDefault("app.leave_head", false) v.SetDefault("app.leakless", false) - v.SetDefault("app.stealth", false) v.SetDefault("app.debug_endpoints", false) v.SetDefault("proxies.entries", []interface{}{}) @@ -348,6 +352,7 @@ func init() { RootCmd.PersistentFlags().IntVarP(&config.App.Timeout, "timeout", "t", 30, "Timeout to fail request") RootCmd.PersistentFlags().StringVarP(&config.Server.ConfigPath, "config", "c", "", "Configuration file path") RootCmd.PersistentFlags().StringVarP(&config.App.BrowserPath, "browser-path", "", "", "Custom browser binary path (Chrome/Chromium/Edge/Brave..)") + RootCmd.PersistentFlags().StringVar(&config.App.ProfilesJSON, "profiles", "", "Path to browser profile catalog JSON") RootCmd.PersistentFlags().BoolVarP(&config.Server.IsVerbose, "verbose", "v", false, "Use verbose output") RootCmd.PersistentFlags().BoolVarP(&config.Server.IsDebug, "debug", "d", false, "Use debug output. Disable headless browser") RootCmd.PersistentFlags().BoolVarP(&config.App.IsBrowserHead, "head", "", false, "Enable browser UI") @@ -356,7 +361,6 @@ func init() { RootCmd.PersistentFlags().BoolVarP(&config.App.IsLeaveHead, "leave", "", false, "Leave browser and tabs opened after search is made") RootCmd.PersistentFlags().StringVarP(&config.Config2Capcha.ApiKey, "2captcha_key", "", "", "2 captcha api key") RootCmd.PersistentFlags().StringVarP(&config.Proxies.Global, "proxy", "x", "", "Force a single proxy for all engines (same as proxies.global)") - RootCmd.PersistentFlags().BoolVarP(&config.App.IsStealth, "stealth", "s", false, "Use stealth browser plugin") RootCmd.PersistentFlags().BoolVar(&config.App.DebugEndpoints, "debug-endpoints", false, "Enable debug-only HTTP endpoints") RootCmd.PersistentFlags().BoolVarP(&config.Server.Insecure, "insecure", "k", false, "Allow insecure TLS connections") RootCmd.PersistentFlags().IntVar(&config.Cache.TTLSeconds, "cache_ttl", 300, "Cache TTL in seconds (0 to disable)") diff --git a/cmd/search.go b/cmd/search.go index 1523b3a..564f0e8 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -117,7 +117,6 @@ func searchBrowser(engineType string, query core.Query, browserProxyURL string, BrowserPath: config.App.BrowserPath, ProxyURL: browserProxyURL, Insecure: config.Server.Insecure, - UseStealth: config.App.IsStealth, } if config.Server.IsDebug { diff --git a/cmd/serve.go b/cmd/serve.go index a7bd409..ac99b4b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -132,7 +132,6 @@ func buildFingerprintBrowserOptions() core.BrowserOpts { Timeout: time.Second * time.Duration(config.App.Timeout), BrowserPath: config.App.BrowserPath, Insecure: config.Server.Insecure, - UseStealth: config.App.IsStealth, } if config.Server.IsDebug { opts.IsHeadless = false diff --git a/config.yaml b/config.yaml index 100f88d..ed01039 100644 --- a/config.yaml +++ b/config.yaml @@ -11,10 +11,10 @@ app: log_format: "text" timeout: 15 # Browser/search timeout in seconds browser_path: "" # Custom browser binary path (chrome/chromium/edge..) + profiles: "" # Optional JSON file path overriding built-in browser profiles head: false # Show browser UI (headful mode) leakless: false # Force browser process cleanup after request leave_head: false # Keep tabs open after request for debugging - stealth: false # Enable stealth browser plugin proxies: # Force a single proxy for all engines. diff --git a/core/browser.go b/core/browser.go index d9b61b2..4f73a33 100644 --- a/core/browser.go +++ b/core/browser.go @@ -2,21 +2,23 @@ package core import ( "context" + "encoding/json" "errors" "fmt" "net/url" "os" + "regexp" "runtime/debug" "strings" "sync" "time" "github.com/go-rod/rod" - "github.com/go-rod/rod/lib/devices" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" - "github.com/go-rod/stealth" + browserprofile "github.com/karust/openserp/core/browser" "github.com/sirupsen/logrus" + "github.com/ysmood/gson" ) // BrowserOpts configures Chromium launch and navigation behavior. @@ -45,8 +47,6 @@ type BrowserOpts struct { ProxyURL string // Insecure allows invalid TLS certificates for browser requests. Insecure bool - // UseStealth enables go-rod stealth page creation. - UseStealth bool // UserAgent optionally overrides browser-reported user agent during emulation. UserAgent string } @@ -71,15 +71,18 @@ type Browser struct { } type browserConnection struct { - mu sync.Mutex - browser *rod.Browser - cachedUserAgent string + mu sync.Mutex + browser *rod.Browser + laneProfiles map[string]browserprofile.Profile } // NewBrowser launches a new Chromium process via Rod launcher and returns a // Browser wrapper configured with proxy and captcha solver settings. func NewBrowser(opts BrowserOpts) (*Browser, error) { opts.Check() + if strings.TrimSpace(opts.UserAgent) != "" { + logrus.Warn("custom user_agent override can reduce profile coherence; use only for diagnostics") + } logrus.WithField("browser_options", fmt.Sprintf("%+v", opts)).Debug("Browser options") path, err := resolveBrowserBinaryPath(opts.BrowserPath, launcher.LookPath) @@ -87,9 +90,20 @@ func NewBrowser(opts BrowserOpts) (*Browser, error) { return nil, err } - // Create launcher - l := launcher.New().Leakless(opts.IsLeakless).Headless(opts.IsHeadless).Set("disable-blink-features", "AutomationControlled"). - Delete("enable-automation") + // Create launcher. + // headless=new uses the full Chrome renderer; legacy --headless disables the + // GPU process entirely, making WebGL context creation fail even with SwiftShader. + // use-angle=swiftshader-webgl (Chrome ≥112) enables a software WebGL renderer. + l := launcher.New().Leakless(opts.IsLeakless). + Set("disable-blink-features", "AutomationControlled"). + Delete("enable-automation"). + Set("use-angle", "swiftshader-webgl"). + Set("ignore-gpu-blocklist") + if opts.IsHeadless { + l = l.HeadlessNew(true) + } else { + l = l.Headless(false) + } if path != "" { logrus.WithField("browser_path", path).Debug("Using browser binary") l = l.Bin(path) @@ -204,35 +218,26 @@ func (b *Browser) newRodBrowser() *rod.Browser { return browser } -func (b *Browser) connectBrowser() (*rod.Browser, string, error) { +func (b *Browser) connectBrowser() (*rod.Browser, error) { browser := b.newRodBrowser() if err := browser.Connect(); err != nil { - return nil, "", err + return nil, err } // Keep cert handling on the persistent browser session. // Proxy runtime can surface MITM certs, and insecure mode is explicit opt-in. if b.ProxyURL != "" || b.Insecure { if err := browser.IgnoreCertErrors(true); err != nil { - return nil, "", err + return nil, err } } - version, err := browser.Version() - if err != nil { - return nil, "", fmt.Errorf("read browser version: %w", err) - } - ua := strings.ReplaceAll(version.UserAgent, "HeadlessChrome/", "Chrome/") - if overrideUA := strings.TrimSpace(b.UserAgent); overrideUA != "" { - ua = overrideUA - } - - return browser, ua, nil + return browser, nil } -func (b *Browser) ensureConnectedBrowser(ctx context.Context, forceReconnect bool) (*rod.Browser, string, error) { +func (b *Browser) ensureConnectedBrowser(ctx context.Context, forceReconnect bool) (*rod.Browser, error) { if b == nil || b.browserAddr == "" { - return nil, "", fmt.Errorf("browser is not initialized") + return nil, fmt.Errorf("browser is not initialized") } state := b.connectionState() @@ -240,26 +245,24 @@ func (b *Browser) ensureConnectedBrowser(ctx context.Context, forceReconnect boo defer state.mu.Unlock() if state.browser == nil || forceReconnect { - connected, ua, err := b.connectBrowser() + connected, err := b.connectBrowser() if err != nil { - return nil, "", err + return nil, err } state.browser = connected - state.cachedUserAgent = ua - return state.browser, ua, nil + return state.browser, nil } if _, err := state.browser.Version(); err != nil { WithRequest(ctx).WithError(err).Debug("Browser ping failed, reconnecting") - connected, ua, reconnectErr := b.connectBrowser() + connected, reconnectErr := b.connectBrowser() if reconnectErr != nil { - return nil, "", reconnectErr + return nil, reconnectErr } state.browser = connected - state.cachedUserAgent = ua } - return state.browser, state.cachedUserAgent, nil + return state.browser, nil } func createIsolatedPage(browser *rod.Browser) (*rod.Page, proto.BrowserBrowserContextID, error) { @@ -329,7 +332,294 @@ func (b *Browser) startProxyAuthHandler(ctx context.Context, browser *rod.Browse return cancel, nil } -// Navigate connects to Chromium, creates a page, applies stealth/emulation and +var chromeVersionPattern = regexp.MustCompile(`(?:HeadlessChrome|Chrome)/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)`) + +func (b *Browser) laneProfile(ctx context.Context, browser *rod.Browser) (browserprofile.Profile, string) { + engine := engineFromContext(ctx) + region := profileRegionFromContext(ctx) + if region == "" { + region = strings.TrimSpace(b.LanguageCode) + } + laneKey := browserprofile.LaneKey(engine, region) + + state := b.connectionState() + state.mu.Lock() + if state.laneProfiles == nil { + state.laneProfiles = make(map[string]browserprofile.Profile) + } + if profile, ok := state.laneProfiles[laneKey]; ok { + state.mu.Unlock() + return profile, laneKey + } + state.mu.Unlock() + + // Resolve profile outside the lock: SelectProfile reads from a separate + // RWMutex-guarded catalog, and applyRuntimeBrowserVersion makes a CDP + // round-trip (browser.Version). Holding state.mu over network I/O would + // serialize all concurrent Navigate calls. + profile := browserprofile.SelectProfile(engine, region) + profile = applyRuntimeBrowserVersion(profile, browser) + if overrideUA := strings.TrimSpace(b.UserAgent); overrideUA != "" { + profile.UserAgent = overrideUA + } + + state.mu.Lock() + if state.laneProfiles == nil { + state.laneProfiles = make(map[string]browserprofile.Profile) + } + if _, exists := state.laneProfiles[laneKey]; !exists { + state.laneProfiles[laneKey] = profile + } else { + profile = state.laneProfiles[laneKey] + } + state.mu.Unlock() + + return profile, laneKey +} + +func applyRuntimeBrowserVersion(profile browserprofile.Profile, browser *rod.Browser) browserprofile.Profile { + fullVersion := "" + if browser != nil { + version, err := browser.Version() + if err == nil && version != nil { + fullVersion = extractChromeVersion(version.UserAgent) + if fullVersion == "" { + fullVersion = extractChromeVersion(version.Product) + } + } + } + if fullVersion == "" { + fullVersion = extractChromeVersion(profile.UserAgent) + } + if fullVersion == "" { + return profile + } + + major := chromeMajorVersion(fullVersion) + if major == "" { + return profile + } + + profile.UserAgent = replaceChromeUserAgentVersion(profile.UserAgent, major+".0.0.0") + profile.UACHBrands = patchBrandVersions(profile.UACHBrands, major, false) + profile.UACHFullVerList = patchBrandVersions(profile.UACHFullVerList, fullVersion, true) + return profile +} + +func extractChromeVersion(value string) string { + matches := chromeVersionPattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(matches) < 2 { + return "" + } + return strings.TrimSpace(matches[1]) +} + +func chromeMajorVersion(fullVersion string) string { + fullVersion = strings.TrimSpace(fullVersion) + if fullVersion == "" { + return "" + } + parts := strings.Split(fullVersion, ".") + if len(parts) == 0 { + return "" + } + return strings.TrimSpace(parts[0]) +} + +func replaceChromeUserAgentVersion(userAgent string, replacement string) string { + replacement = strings.TrimSpace(replacement) + if replacement == "" { + return strings.ReplaceAll(strings.TrimSpace(userAgent), "HeadlessChrome/", "Chrome/") + } + normalized := strings.ReplaceAll(strings.TrimSpace(userAgent), "HeadlessChrome/", "Chrome/") + return chromeVersionPattern.ReplaceAllString(normalized, "Chrome/"+replacement) +} + +func patchBrandVersions(values []browserprofile.BrandVersion, version string, patchNotABrand bool) []browserprofile.BrandVersion { + version = strings.TrimSpace(version) + if version == "" { + return values + } + + out := make([]browserprofile.BrandVersion, 0, len(values)) + for _, value := range values { + item := value + brandLower := strings.ToLower(strings.TrimSpace(item.Brand)) + if brandLower == "chromium" || brandLower == "google chrome" { + item.Version = version + } else if patchNotABrand && strings.Contains(brandLower, "not_a brand") && strings.Count(version, ".") == 3 { + item.Version = "24.0.0.0" + } + out = append(out, item) + } + return out +} + +func navigatorPlatformForProfile(profile browserprofile.Profile) string { + switch strings.ToLower(strings.TrimSpace(profile.Platform)) { + case "windows": + return "Win32" + case "macos": + return "MacIntel" + case "linux": + return "Linux x86_64" + default: + return strings.TrimSpace(profile.Platform) + } +} + +func applyProfile(page *rod.Page, profile browserprofile.Profile) error { + if page == nil { + return fmt.Errorf("page is nil") + } + + navigatorLangs := profileNavigatorLanguages(profile) + acceptLanguage := strings.TrimSpace(profile.AcceptLanguage) + if acceptLanguage == "" { + acceptLanguage = navigatorLangs[0] + } + locale := strings.TrimSpace(profile.Locale) + if locale == "" { + locale = navigatorLangs[0] + } + + width := profile.Viewport.Width + height := profile.Viewport.Height + if width <= 0 { + width = 1920 + } + if height <= 0 { + height = 1080 + } + + metadata := &proto.EmulationUserAgentMetadata{ + Brands: toProtoBrandVersions(profile.UACHBrands), + FullVersionList: toProtoBrandVersions(profile.UACHFullVerList), + Platform: strings.TrimSpace(profile.Platform), + PlatformVersion: strings.TrimSpace(profile.PlatformVersion), + Architecture: strings.TrimSpace(profile.Architecture), + Bitness: strings.TrimSpace(profile.Bitness), + Mobile: profile.Mobile, + } + + if err := (proto.NetworkSetUserAgentOverride{ + UserAgent: strings.TrimSpace(profile.UserAgent), + AcceptLanguage: acceptLanguage, + Platform: navigatorPlatformForProfile(profile), + UserAgentMetadata: metadata, + }).Call(page); err != nil { + return fmt.Errorf("set user agent override failed: %w", err) + } + + if err := (proto.EmulationSetLocaleOverride{ + Locale: locale, + }).Call(page); err != nil { + return fmt.Errorf("set locale override failed: %w", err) + } + + if err := (proto.EmulationSetTimezoneOverride{ + TimezoneID: strings.TrimSpace(profile.Timezone), + }).Call(page); err != nil { + return fmt.Errorf("set timezone override failed: %w", err) + } + + if err := (proto.EmulationSetDeviceMetricsOverride{ + Width: width, + Height: height, + DeviceScaleFactor: 1, + Mobile: profile.Mobile, + ScreenWidth: &width, + ScreenHeight: &height, + }).Call(page); err != nil { + return fmt.Errorf("set device metrics failed: %w", err) + } + + if err := (proto.NetworkSetExtraHTTPHeaders{ + Headers: proto.NetworkHeaders{ + "Accept-Language": gson.New(acceptLanguage), + }, + }).Call(page); err != nil { + return fmt.Errorf("set extra headers failed: %w", err) + } + + if err := evalPatchScript(page, navigatorLangs, width, height); err != nil { + return err + } + + return nil +} + +func profileNavigatorLanguages(profile browserprofile.Profile) []string { + langs := make([]string, 0, len(profile.NavigatorLangs)) + for _, language := range profile.NavigatorLangs { + trimmed := strings.TrimSpace(language) + if trimmed == "" { + continue + } + langs = append(langs, trimmed) + } + if len(langs) > 0 { + return langs + } + + acceptLanguage := strings.TrimSpace(profile.AcceptLanguage) + if acceptLanguage != "" { + parts := strings.Split(acceptLanguage, ",") + langs = make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if idx := strings.Index(part, ";"); idx >= 0 { + part = strings.TrimSpace(part[:idx]) + } + if part != "" { + langs = append(langs, part) + } + } + if len(langs) > 0 { + return langs + } + } + + if locale := strings.TrimSpace(profile.Locale); locale != "" { + return []string{locale} + } + return []string{"en-US"} +} + +func toProtoBrandVersions(values []browserprofile.BrandVersion) []*proto.EmulationUserAgentBrandVersion { + out := make([]*proto.EmulationUserAgentBrandVersion, 0, len(values)) + for _, value := range values { + brand := strings.TrimSpace(value.Brand) + version := strings.TrimSpace(value.Version) + if brand == "" || version == "" { + continue + } + out = append(out, &proto.EmulationUserAgentBrandVersion{ + Brand: brand, + Version: version, + }) + } + return out +} + +func evalPatchScript(page *rod.Page, langs []string, width, height int) error { + langsJSON, err := json.Marshal(langs) + if err != nil { + return fmt.Errorf("marshal navigator languages: %w", err) + } + args := fmt.Sprintf("const __langs = %s;\nconst __w = %d;\nconst __h = %d;\n", + string(langsJSON), width, height) + _, err = page.EvalOnNewDocument(args + string(browserprofile.PatchJS)) + if err != nil { + return fmt.Errorf("eval patch script: %w", err) + } + return nil +} + +// Navigate connects to Chromium, creates a page, applies a coherent profile and // proxy auth, then navigates to URL. It returns an initialized page ready for // selector queries, or an error when browser setup/navigation fails. func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) { @@ -340,7 +630,7 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) { WithRequest(ctx).WithField("url", URL).Debug("Navigate") - browser, ua, err := b.ensureConnectedBrowser(ctx, false) + browser, err := b.ensureConnectedBrowser(ctx, false) if err != nil { return nil, fmt.Errorf("browser connect failed: %w", err) } @@ -348,7 +638,7 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) { page, browserContextID, err := createIsolatedPage(browser) if err != nil { // Single-shot reconnect for stale websocket sessions. - browser, ua, err = b.ensureConnectedBrowser(ctx, true) + browser, err = b.ensureConnectedBrowser(ctx, true) if err != nil { return nil, fmt.Errorf("create isolated page failed, reconnect also failed: %w", err) } @@ -379,33 +669,10 @@ func (b *Browser) Navigate(ctx context.Context, URL string) (*rod.Page, error) { defer cancelProxyAuth() } - if b.UseStealth { - if _, err := page.EvalOnNewDocument(stealth.JS); err != nil { - closeOnErr() - return nil, fmt.Errorf("create stealth page failed: %w", err) - } - } - - if err := page.Emulate(devices.Device{ - AcceptLanguage: b.LanguageCode, - UserAgent: ua, - }); err != nil { + profile, laneKey := b.laneProfile(ctx, browser) + if err := applyProfile(page, profile); err != nil { closeOnErr() - return nil, fmt.Errorf("emulate page failed: %w", err) - } - - if !b.UseStealth { - if err := (proto.EmulationSetDeviceMetricsOverride{ - Width: 1920, - Height: 1080, - DeviceScaleFactor: 1, - Mobile: false, - ScreenWidth: &[]int{1920}[0], - ScreenHeight: &[]int{1080}[0], - }).Call(page); err != nil { - closeOnErr() - return nil, fmt.Errorf("set device metrics failed: %w", err) - } + return nil, fmt.Errorf("apply profile %s (%s) failed: %w", profile.ID, laneKey, err) } page = page.Context(ctx) @@ -464,6 +731,7 @@ func (b *Browser) Close() error { } state.browser = nil + state.laneProfiles = nil if err := browser.Close(); err != nil && !isBrowserClosedError(err) { return err } diff --git a/core/browser/patch.js b/core/browser/patch.js new file mode 100644 index 0000000..c1f9249 --- /dev/null +++ b/core/browser/patch.js @@ -0,0 +1,73 @@ +// Stealth patches injected via EvalOnNewDocument. +// Arguments are injected as a leading const block by the Go caller: +// const __langs = [...]; // navigator_langs from profile +// const __w = 1920; // viewport width +// const __h = 1080; // viewport height +(() => { + 'use strict'; + + // --- navigator.language / navigator.languages --- + // + // CDP Network.setUserAgentOverride(acceptLanguage) sets the HTTP header but + // does NOT update navigator.language / navigator.languages in JS. Those are + // read from the browser profile at context creation. On Linux headless they + // reflect the system ICU locale (usually "en-US" regardless of profile). + // + // We patch them here. The key to being undetectable: define with a getter + // first (requires configurable:true), then immediately lock the descriptor + // back to configurable:false so it looks exactly like a native property. + const primary = __langs.length ? __langs[0] : 'en-US'; + + const sealGetter = (target, prop, fn) => { + try { + Object.defineProperty(target, prop, { + get: fn, + set: undefined, + enumerable: true, + configurable: true, // must be true to set a getter + }); + Object.defineProperty(target, prop, { + configurable: false, // seal: now indistinguishable from native + }); + } catch (_) {} + }; + + const patchLangs = (target) => { + if (!target) return; + sealGetter(target, 'language', () => primary); + sealGetter(target, 'languages', () => Object.freeze(__langs.slice())); + }; + + // Patch the navigator instance (Linux headless stores own-props here) + // and Navigator.prototype (other platforms / future Chrome versions). + patchLangs(navigator); + patchLangs(Object.getPrototypeOf(navigator)); + if (typeof WorkerNavigator !== 'undefined') { + patchLangs(WorkerNavigator.prototype); + } + + // --- screen dimensions --- + // + // EmulationSetDeviceMetricsOverride sets the CSS viewport but leaves + // window.screen.* at headless defaults. Checkers compare screen size + // against viewport and flag the mismatch as automation. + // + // Screen properties live on Screen.prototype as non-configurable getters. + // Patching the prototype makes them look native. + const patchScreen = () => { + const proto = typeof Screen !== 'undefined' ? Screen.prototype : null; + if (!proto) return; + sealGetter(proto, 'width', () => __w); + sealGetter(proto, 'height', () => __h); + sealGetter(proto, 'availWidth', () => __w); + sealGetter(proto, 'availHeight', () => __h); + sealGetter(proto, 'availLeft', () => 0); + sealGetter(proto, 'availTop', () => 0); + }; + patchScreen(); + + // window.outerWidth/Height are own configurable properties; Chrome normally + // sets them to match the OS window size. In headless they are 0. + sealGetter(window, 'outerWidth', () => __w); + sealGetter(window, 'outerHeight', () => __h); +})(); diff --git a/core/browser/profile.go b/core/browser/profile.go new file mode 100644 index 0000000..f606d70 --- /dev/null +++ b/core/browser/profile.go @@ -0,0 +1,294 @@ +package browser + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "runtime" + "strings" + "sync" +) + +type BrandVersion struct { + Brand string `json:"brand"` + Version string `json:"version"` +} + +type Viewport struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type Profile struct { + ID string `json:"id"` + UserAgent string `json:"user_agent"` + UACHBrands []BrandVersion `json:"uach_brands"` + UACHFullVerList []BrandVersion `json:"uach_full_version_list"` + Platform string `json:"platform"` + PlatformVersion string `json:"platform_version"` + Architecture string `json:"architecture"` + Bitness string `json:"bitness"` + Mobile bool `json:"mobile"` + AcceptLanguage string `json:"accept_language"` + NavigatorLangs []string `json:"navigator_langs"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` + Viewport Viewport `json:"viewport"` +} + +type catalogConfig struct { + Profiles []Profile `json:"profiles"` + LaneProfileIDs map[string]string `json:"lane_profile_ids"` + DefaultRegionByEngine map[string]string `json:"default_region_by_engine"` +} + +const ( + ProfileChromeWinUS = "chrome-win-us" + ProfileChromeWinRU = "chrome-win-ru" + ProfileChromeMacUS = "chrome-macos-us" + ProfileChromeLinuxUS = "chrome-linux-us" + ProfileChromeLinuxRU = "chrome-linux-ru" +) + +//go:embed profiles.json +var defaultProfilesJSON []byte + +//go:embed patch.js +var PatchJS []byte + +var profileCatalogMu sync.RWMutex + +var catalog = map[string]Profile{} + +var laneProfileIDs = map[string]string{} + +var defaultRegionByEngine = map[string]string{} + +func init() { + if err := loadProfilesFromJSONBytes(defaultProfilesJSON); err != nil { + panic(fmt.Sprintf("load embedded browser profiles: %v", err)) + } +} + +func LoadProfilesFromJSON(path string) error { + path = strings.TrimSpace(path) + if path == "" { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read profiles json %q: %w", path, err) + } + + if err := loadProfilesFromJSONBytes(data); err != nil { + return fmt.Errorf("parse profiles json %q: %w", path, err) + } + return nil +} + +func loadProfilesFromJSONBytes(data []byte) error { + var cfg catalogConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + + if len(cfg.Profiles) == 0 { + return fmt.Errorf("profiles list is empty") + } + + nextCatalog := make(map[string]Profile, len(cfg.Profiles)) + for i, profile := range cfg.Profiles { + profile.ID = strings.TrimSpace(profile.ID) + if profile.ID == "" { + return fmt.Errorf("profiles[%d].id is empty", i) + } + if strings.TrimSpace(profile.UserAgent) == "" { + return fmt.Errorf("profiles[%d].user_agent is empty", i) + } + if _, exists := nextCatalog[profile.ID]; exists { + return fmt.Errorf("duplicate profile id %q", profile.ID) + } + nextCatalog[profile.ID] = profile + } + + nextLaneProfileIDs := make(map[string]string, len(cfg.LaneProfileIDs)) + for rawLaneKey, profileID := range cfg.LaneProfileIDs { + laneKey, err := normalizeLaneKey(rawLaneKey) + if err != nil { + return err + } + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return fmt.Errorf("lane profile id for %q is empty", laneKey) + } + if _, exists := nextCatalog[profileID]; !exists { + return fmt.Errorf("lane %q references unknown profile id %q", laneKey, profileID) + } + nextLaneProfileIDs[laneKey] = profileID + } + + nextDefaultRegionByEngine := map[string]string{ + "yandex": "ru", + } + for engine, region := range cfg.DefaultRegionByEngine { + engine = NormalizeEngine(engine) + if engine == "" { + return fmt.Errorf("default_region_by_engine contains empty engine key") + } + nextDefaultRegionByEngine[engine] = normalizeConfiguredRegion(region) + } + + profileCatalogMu.Lock() + catalog = nextCatalog + laneProfileIDs = nextLaneProfileIDs + defaultRegionByEngine = nextDefaultRegionByEngine + profileCatalogMu.Unlock() + + return nil +} + +func normalizeLaneKey(value string) (string, error) { + value = strings.TrimSpace(value) + parts := strings.SplitN(value, ":", 2) + if len(parts) != 2 { + return "", fmt.Errorf("invalid lane key %q, expected engine:region", value) + } + engine := NormalizeEngine(parts[0]) + if engine == "" { + return "", fmt.Errorf("invalid lane key %q, engine is empty", value) + } + region := normalizeConfiguredRegion(parts[1]) + return engine + ":" + region, nil +} + +func normalizeConfiguredRegion(region string) string { + region = strings.TrimSpace(region) + if region == "" { + return "us" + } + return NormalizeRegion(region) +} + +func Catalog() []Profile { + profileCatalogMu.RLock() + defer profileCatalogMu.RUnlock() + + out := make([]Profile, 0, len(catalog)) + for _, profile := range catalog { + out = append(out, profile) + } + return out +} + +func SelectProfile(engine string, region string) Profile { + engine = NormalizeEngine(engine) + region = NormalizeRegion(region) + if region == "" { + region = DefaultRegionForEngine(engine) + } + laneKey := engine + ":" + region + + profileCatalogMu.RLock() + profileID, ok := laneProfileIDs[laneKey] + profileCatalogMu.RUnlock() + if ok { + return profileByID(profileID) + } + return profileByID(defaultProfileID(region)) +} + +func LaneKey(engine string, region string) string { + engine = NormalizeEngine(engine) + if engine == "" { + engine = "unknown" + } + + region = NormalizeRegion(region) + if region == "" { + region = DefaultRegionForEngine(engine) + } + + return engine + ":" + region +} + +func DefaultRegionForEngine(engine string) string { + engine = NormalizeEngine(engine) + + profileCatalogMu.RLock() + defer profileCatalogMu.RUnlock() + + if region, ok := defaultRegionByEngine[engine]; ok { + return region + } + return "us" +} + +func NormalizeEngine(engine string) string { + return strings.ToLower(strings.TrimSpace(engine)) +} + +func NormalizeRegion(region string) string { + region = strings.TrimSpace(strings.ToLower(region)) + if region == "" { + return "" + } + + if idx := strings.Index(region, ","); idx >= 0 { + region = region[:idx] + } + if idx := strings.Index(region, ";"); idx >= 0 { + region = region[:idx] + } + + region = strings.ReplaceAll(region, "_", "-") + if idx := strings.Index(region, "-"); idx >= 0 { + region = region[:idx] + } + + switch region { + case "ru", "be", "kz", "ky": + return "ru" + default: + return "us" + } +} + +func profileByID(profileID string) Profile { + profileCatalogMu.RLock() + defer profileCatalogMu.RUnlock() + + if profile, ok := catalog[profileID]; ok { + return profile + } + if fallback, ok := catalog[defaultProfileID("us")]; ok { + return fallback + } + for _, profile := range catalog { + return profile + } + return Profile{} +} + +func defaultProfileID(region string) string { + region = NormalizeRegion(region) + if region == "" { + region = "us" + } + + switch runtime.GOOS { + case "windows": + if region == "ru" { + return ProfileChromeWinRU + } + return ProfileChromeWinUS + case "darwin": + return ProfileChromeMacUS + default: + if region == "ru" { + return ProfileChromeLinuxRU + } + return ProfileChromeLinuxUS + } +} diff --git a/core/browser/profile_coherence_test.go b/core/browser/profile_coherence_test.go new file mode 100644 index 0000000..a3f4173 --- /dev/null +++ b/core/browser/profile_coherence_test.go @@ -0,0 +1,200 @@ +//go:build integration +// +build integration + +package browser_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-rod/rod" + "github.com/karust/openserp/core" + browserprofile "github.com/karust/openserp/core/browser" + "github.com/karust/openserp/testutil" +) + +func TestProfileCoherence(t *testing.T) { + testutil.RequireIntegration(t) + + fixture := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`coherenceok`)) + })) + defer fixture.Close() + + browser, err := core.NewBrowser(core.BrowserOpts{ + IsHeadless: true, + IsLeakless: false, + Timeout: 20 * time.Second, + }) + if err != nil { + t.Fatalf("create browser: %v", err) + } + defer func() { + if closeErr := browser.Close(); closeErr != nil { + t.Fatalf("close browser: %v", closeErr) + } + }() + + cases := []struct { + name string + engine string + region string + }{ + { + name: "windows lane", + engine: "google", + region: "ru", + }, + { + name: "mac lane", + engine: "bing", + region: "en-US", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + expected := browserprofile.SelectProfile(tc.engine, tc.region) + ctx := core.WithEngine(context.Background(), tc.engine) + ctx = core.WithProfileRegion(ctx, tc.region) + + page, err := browser.Navigate(ctx, fixture.URL) + if err != nil { + t.Fatalf("navigate fixture: %v", err) + } + defer func() { + if closeErr := core.ClosePageWithTimeout(context.Background(), page, time.Second); closeErr != nil { + t.Fatalf("close page: %v", closeErr) + } + }() + + got, err := browserProfileSurface(page) + if err != nil { + t.Fatalf("collect profile surfaces: %v", err) + } + + if got.UserAgent != expected.UserAgent { + t.Fatalf("navigator.userAgent mismatch:\nexpected: %s\nactual: %s", expected.UserAgent, got.UserAgent) + } + if got.Platform != expected.Platform { + t.Fatalf("navigator.userAgentData.platform mismatch: expected %q got %q", expected.Platform, got.Platform) + } + if got.NavigatorPlatform != expectedNavigatorPlatform(expected.Platform) { + t.Fatalf("navigator.platform mismatch: expected %q got %q", expectedNavigatorPlatform(expected.Platform), got.NavigatorPlatform) + } + if got.Locale != expected.Locale { + t.Fatalf("Intl locale mismatch: expected %q got %q", expected.Locale, got.Locale) + } + if got.Timezone != expected.Timezone { + t.Fatalf("Intl timezone mismatch: expected %q got %q", expected.Timezone, got.Timezone) + } + if len(got.NavigatorLanguages) == 0 { + t.Fatal("navigator.languages is empty") + } + if got.NavigatorLanguages[0] != expected.NavigatorLangs[0] { + t.Fatalf("navigator.languages[0] mismatch: expected %q got %q", expected.NavigatorLangs[0], got.NavigatorLanguages[0]) + } + if got.WebdriverType != "undefined" { + t.Fatalf("navigator.webdriver expected undefined, got %q", got.WebdriverType) + } + if got.WebdriverOwnPropPresent { + t.Fatal("navigator own property 'webdriver' should not be present") + } + if got.WorkerUserAgent != got.UserAgent { + t.Fatalf("worker userAgent mismatch: main %q worker %q", got.UserAgent, got.WorkerUserAgent) + } + if got.WorkerPlatform != got.NavigatorPlatform { + t.Fatalf("worker platform mismatch: main %q worker %q", got.NavigatorPlatform, got.WorkerPlatform) + } + if len(got.WorkerNavigatorLangs) == 0 { + t.Fatal("worker navigator.languages is empty") + } + if got.WorkerNavigatorLangs[0] != got.NavigatorLanguages[0] { + t.Fatalf("worker navigator.languages[0] mismatch: main %q worker %q", got.NavigatorLanguages[0], got.WorkerNavigatorLangs[0]) + } + if got.WorkerTimezone != got.Timezone { + t.Fatalf("worker timezone mismatch: main %q worker %q", got.Timezone, got.WorkerTimezone) + } + }) + } +} + +type profileSurface struct { + UserAgent string `json:"userAgent"` + Platform string `json:"platform"` + NavigatorPlatform string `json:"navigatorPlatform"` + NavigatorLanguages []string `json:"navigatorLanguages"` + Timezone string `json:"timezone"` + Locale string `json:"locale"` + WebdriverType string `json:"webdriverType"` + WebdriverOwnPropPresent bool `json:"webdriverOwnPropPresent"` + WorkerUserAgent string `json:"workerUserAgent"` + WorkerPlatform string `json:"workerPlatform"` + WorkerNavigatorLangs []string `json:"workerNavigatorLangs"` + WorkerTimezone string `json:"workerTimezone"` +} + +func browserProfileSurface(page *rod.Page) (profileSurface, error) { + result, err := page.Eval(`async () => { + const workerData = await new Promise((resolve) => { + try { + const source = "self.onmessage=()=>{self.postMessage({userAgent:self.navigator.userAgent||'',platform:self.navigator.platform||'',navigatorLanguages:Array.from(self.navigator.languages||[]),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone||''});};"; + const blob = new Blob([source], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + worker.onmessage = (event) => { + resolve(event.data || {}); + worker.terminate(); + URL.revokeObjectURL(url); + }; + worker.onerror = () => { + resolve({}); + worker.terminate(); + URL.revokeObjectURL(url); + }; + worker.postMessage('run'); + } catch (_) { + resolve({}); + } + }); + return { + userAgent: navigator.userAgent || "", + platform: navigator.userAgentData ? (navigator.userAgentData.platform || "") : "", + navigatorPlatform: navigator.platform || "", + navigatorLanguages: Array.from(navigator.languages || []), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "", + locale: Intl.DateTimeFormat().resolvedOptions().locale || "", + webdriverType: typeof navigator.webdriver, + webdriverOwnPropPresent: Object.getOwnPropertyNames(navigator).includes('webdriver'), + workerUserAgent: workerData.userAgent || "", + workerPlatform: workerData.platform || "", + workerNavigatorLangs: Array.from(workerData.navigatorLanguages || []), + workerTimezone: workerData.timezone || "", + }; + }`) + if err != nil { + return profileSurface{}, err + } + + var out profileSurface + if err := result.Value.Unmarshal(&out); err != nil { + return profileSurface{}, fmt.Errorf("decode eval payload: %w", err) + } + return out, nil +} + +func expectedNavigatorPlatform(platform string) string { + switch platform { + case "Windows": + return "Win32" + case "macOS": + return "MacIntel" + default: + return "Linux x86_64" + } +} diff --git a/core/browser/profile_test.go b/core/browser/profile_test.go new file mode 100644 index 0000000..72d485c --- /dev/null +++ b/core/browser/profile_test.go @@ -0,0 +1,169 @@ +package browser + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSelectProfile(t *testing.T) { + tests := []struct { + name string + engine string + region string + wantLocale string + wantTimezone string + }{ + { + name: "google ru lane", + engine: "google", + region: "ru", + wantLocale: "ru-RU", + wantTimezone: "Europe/Moscow", + }, + { + name: "yandex defaults to ru", + engine: "yandex", + region: "", + wantLocale: "ru-RU", + wantTimezone: "Europe/Moscow", + }, + { + name: "google default lane uses us profile", + engine: "google", + region: "en", + wantLocale: "en-US", + wantTimezone: "America/New_York", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + profile := SelectProfile(tt.engine, tt.region) + if profile.ID == "" { + t.Fatal("expected non-empty profile ID") + } + if profile.Locale != tt.wantLocale { + t.Fatalf("expected locale %q, got %q", tt.wantLocale, profile.Locale) + } + if profile.Timezone != tt.wantTimezone { + t.Fatalf("expected timezone %q, got %q", tt.wantTimezone, profile.Timezone) + } + if profile.Platform == "" { + t.Fatal("expected non-empty platform") + } + }) + } +} + +func TestNormalizeRegion(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: "", want: ""}, + {input: "ru", want: "ru"}, + {input: "RU", want: "ru"}, + {input: "ru-RU", want: "ru"}, + {input: "ru_RU", want: "ru"}, + {input: "ru-RU,ru;q=0.9", want: "ru"}, + {input: "en-US,en;q=0.9", want: "us"}, + {input: "de", want: "us"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := NormalizeRegion(tt.input); got != tt.want { + t.Fatalf("NormalizeRegion(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestLoadProfilesFromJSON(t *testing.T) { + originalCatalog, originalLaneProfiles, originalDefaultRegions := snapshotProfileState() + t.Cleanup(func() { + restoreProfileState(originalCatalog, originalLaneProfiles, originalDefaultRegions) + }) + + path := filepath.Join(t.TempDir(), "profiles.json") + payload := `{ + "profiles": [ + { + "id": "custom-ru", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "uach_brands": [ + {"brand": "Chromium", "version": "136"} + ], + "uach_full_version_list": [ + {"brand": "Chromium", "version": "136.0.0.0"} + ], + "platform": "Linux", + "platform_version": "6.0.0", + "architecture": "x86", + "mobile": false, + "accept_language": "ru-RU,ru;q=0.9", + "navigator_langs": ["ru-RU"], + "locale": "ru-RU", + "timezone": "Europe/Moscow", + "viewport": {"width": 1920, "height": 1080} + } + ], + "lane_profile_ids": { + "google:ru-RU": "custom-ru" + }, + "default_region_by_engine": { + "google": "ru-RU" + } + }` + if err := os.WriteFile(path, []byte(payload), 0o644); err != nil { + t.Fatalf("write profiles json: %v", err) + } + + if err := LoadProfilesFromJSON(path); err != nil { + t.Fatalf("load profiles json: %v", err) + } + + if got := DefaultRegionForEngine("google"); got != "ru" { + t.Fatalf("expected google default region ru, got %q", got) + } + + profile := SelectProfile("google", "") + if profile.ID != "custom-ru" { + t.Fatalf("expected custom profile id custom-ru, got %q", profile.ID) + } + if profile.Timezone != "Europe/Moscow" { + t.Fatalf("expected timezone Europe/Moscow, got %q", profile.Timezone) + } +} + +func snapshotProfileState() (map[string]Profile, map[string]string, map[string]string) { + profileCatalogMu.RLock() + defer profileCatalogMu.RUnlock() + + catalogCopy := make(map[string]Profile, len(catalog)) + for k, v := range catalog { + catalogCopy[k] = v + } + + laneProfilesCopy := make(map[string]string, len(laneProfileIDs)) + for k, v := range laneProfileIDs { + laneProfilesCopy[k] = v + } + + defaultRegionsCopy := make(map[string]string, len(defaultRegionByEngine)) + for k, v := range defaultRegionByEngine { + defaultRegionsCopy[k] = v + } + + return catalogCopy, laneProfilesCopy, defaultRegionsCopy +} + +func restoreProfileState(catalogState map[string]Profile, laneProfiles map[string]string, defaultRegions map[string]string) { + profileCatalogMu.Lock() + defer profileCatalogMu.Unlock() + + catalog = catalogState + laneProfileIDs = laneProfiles + defaultRegionByEngine = defaultRegions +} diff --git a/core/browser/profiles.json b/core/browser/profiles.json new file mode 100644 index 0000000..69c290a --- /dev/null +++ b/core/browser/profiles.json @@ -0,0 +1,243 @@ +{ + "profiles": [ + { + "id": "chrome-win-us", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "uach_brands": [ + { + "brand": "Not_A Brand", + "version": "24" + }, + { + "brand": "Chromium", + "version": "136" + }, + { + "brand": "Google Chrome", + "version": "136" + } + ], + "uach_full_version_list": [ + { + "brand": "Not_A Brand", + "version": "24.0.0.0" + }, + { + "brand": "Chromium", + "version": "136.0.0.0" + }, + { + "brand": "Google Chrome", + "version": "136.0.0.0" + } + ], + "platform": "Windows", + "platform_version": "15.0.0", + "architecture": "x86", + "bitness": "64", + "mobile": false, + "accept_language": "en-US,en;q=0.9", + "navigator_langs": [ + "en-US" + ], + "locale": "en-US", + "timezone": "America/New_York", + "viewport": { + "width": 1920, + "height": 1080 + } + }, + { + "id": "chrome-win-ru", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "uach_brands": [ + { + "brand": "Not_A Brand", + "version": "24" + }, + { + "brand": "Chromium", + "version": "136" + }, + { + "brand": "Google Chrome", + "version": "136" + } + ], + "uach_full_version_list": [ + { + "brand": "Not_A Brand", + "version": "24.0.0.0" + }, + { + "brand": "Chromium", + "version": "136.0.0.0" + }, + { + "brand": "Google Chrome", + "version": "136.0.0.0" + } + ], + "platform": "Windows", + "platform_version": "15.0.0", + "architecture": "x86", + "bitness": "64", + "mobile": false, + "accept_language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", + "navigator_langs": [ + "ru-RU" + ], + "locale": "ru-RU", + "timezone": "Europe/Moscow", + "viewport": { + "width": 1920, + "height": 1080 + } + }, + { + "id": "chrome-macos-us", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "uach_brands": [ + { + "brand": "Not_A Brand", + "version": "24" + }, + { + "brand": "Chromium", + "version": "136" + }, + { + "brand": "Google Chrome", + "version": "136" + } + ], + "uach_full_version_list": [ + { + "brand": "Not_A Brand", + "version": "24.0.0.0" + }, + { + "brand": "Chromium", + "version": "136.0.0.0" + }, + { + "brand": "Google Chrome", + "version": "136.0.0.0" + } + ], + "platform": "macOS", + "platform_version": "14.0.0", + "architecture": "x86", + "bitness": "64", + "mobile": false, + "accept_language": "en-US,en;q=0.9", + "navigator_langs": [ + "en-US" + ], + "locale": "en-US", + "timezone": "America/Los_Angeles", + "viewport": { + "width": 1680, + "height": 1050 + } + }, + { + "id": "chrome-linux-us", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "uach_brands": [ + { + "brand": "Not_A Brand", + "version": "24" + }, + { + "brand": "Chromium", + "version": "136" + }, + { + "brand": "Google Chrome", + "version": "136" + } + ], + "uach_full_version_list": [ + { + "brand": "Not_A Brand", + "version": "24.0.0.0" + }, + { + "brand": "Chromium", + "version": "136.0.0.0" + }, + { + "brand": "Google Chrome", + "version": "136.0.0.0" + } + ], + "platform": "Linux", + "platform_version": "6.0.0", + "architecture": "x86", + "bitness": "64", + "mobile": false, + "accept_language": "en-US,en;q=0.9", + "navigator_langs": [ + "en-US" + ], + "locale": "en-US", + "timezone": "America/New_York", + "viewport": { + "width": 1920, + "height": 1080 + } + }, + { + "id": "chrome-linux-ru", + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + "uach_brands": [ + { + "brand": "Not_A Brand", + "version": "24" + }, + { + "brand": "Chromium", + "version": "136" + }, + { + "brand": "Google Chrome", + "version": "136" + } + ], + "uach_full_version_list": [ + { + "brand": "Not_A Brand", + "version": "24.0.0.0" + }, + { + "brand": "Chromium", + "version": "136.0.0.0" + }, + { + "brand": "Google Chrome", + "version": "136.0.0.0" + } + ], + "platform": "Linux", + "platform_version": "6.0.0", + "architecture": "x86", + "bitness": "64", + "mobile": false, + "accept_language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", + "navigator_langs": [ + "ru-RU" + ], + "locale": "ru-RU", + "timezone": "Europe/Moscow", + "viewport": { + "width": 1920, + "height": 1080 + } + } + ], + "lane_profile_ids": {}, + "default_region_by_engine": { + "yandex": "ru" + } +} diff --git a/core/browser_test.go b/core/browser_test.go index 3cbb66b..44bc1bb 100644 --- a/core/browser_test.go +++ b/core/browser_test.go @@ -113,15 +113,13 @@ func TestFingerprintDetectors(t *testing.T) { criticalFailures := make([]string, 0) for _, detector := range detectors.All() { - for _, useStealth := range []bool{false, true} { - report := runFingerprintDetector(t, detector, useStealth) - key := fmt.Sprintf("%s_%s", detector.Name(), stealthModeLabel(useStealth)) - reports[key] = report + report := runFingerprintDetector(t, detector) + key := detector.Name() + reports[key] = report - if len(report.Summary.Critical) > 0 { - for _, critical := range report.Summary.Critical { - criticalFailures = append(criticalFailures, fmt.Sprintf("%s:%s", key, critical)) - } + if len(report.Summary.Critical) > 0 { + for _, critical := range report.Summary.Critical { + criticalFailures = append(criticalFailures, fmt.Sprintf("%s:%s", key, critical)) } } } @@ -142,31 +140,28 @@ func TestFingerprintDetectors(t *testing.T) { } } -func runFingerprintDetector(t *testing.T, detector fpcheck.Detector, useStealth bool) fpcheck.Report { +func runFingerprintDetector(t *testing.T, detector fpcheck.Detector) fpcheck.Report { t.Helper() - label := stealthModeLabel(useStealth) opts := BrowserOpts{ IsHeadless: true, IsLeakless: false, Timeout: 20 * time.Second, - UseStealth: useStealth, } browser, err := NewBrowser(opts) if err != nil { - t.Fatalf("create browser (%s): %v", label, err) + t.Fatalf("create browser: %v", err) } defer closeTestBrowser(t, browser) - report, err := fpcheck.Run(context.Background(), browser, detector, useStealth, botFingerprintArtifactDir) + report, err := fpcheck.Run(context.Background(), browser, detector, botFingerprintArtifactDir) if err != nil { - t.Fatalf("run detector %s (%s): %v", detector.Name(), label, err) + t.Fatalf("run detector %s: %v", detector.Name(), err) } t.Logf( - "Fingerprint %s (%s): passed=%d failed=%d critical=%d", + "Fingerprint %s: passed=%d failed=%d critical=%d", detector.Name(), - label, report.Summary.Passed, report.Summary.Failed, len(report.Summary.Critical), @@ -191,13 +186,6 @@ func writeFingerprintReport(path string, reports map[string]fpcheck.Report) erro return nil } -func stealthModeLabel(useStealth bool) string { - if useStealth { - return fpcheck.ModeStealthOn - } - return fpcheck.ModeStealthOff -} - func closeTestBrowser(t *testing.T, browser *Browser) { t.Helper() if browser == nil { diff --git a/core/fpcheck/detector.go b/core/fpcheck/detector.go index cb20f13..7ccf6ff 100644 --- a/core/fpcheck/detector.go +++ b/core/fpcheck/detector.go @@ -25,7 +25,6 @@ type Summary struct { type Report struct { DetectorName string `json:"detector_name"` URL string `json:"url"` - UseStealth bool `json:"use_stealth"` CapturedAtUTC string `json:"captured_at_utc"` Screenshot string `json:"screenshot_path"` Detections map[string]Detection `json:"detections"` diff --git a/core/fpcheck/detectors/custom.go b/core/fpcheck/detectors/custom.go index de76cc8..f3f6c3a 100644 --- a/core/fpcheck/detectors/custom.go +++ b/core/fpcheck/detectors/custom.go @@ -36,7 +36,7 @@ func (c Custom) URL() string { func (c Custom) Extract(ctx context.Context, page *rod.Page) (map[string]fpcheck.Detection, string, error) { err := waitFor(ctx, 15*time.Second, 200*time.Millisecond, func() (bool, error) { - hasBody, _, err := page.Has("body") + hasBody, _, err := page.Has("pre") if err != nil { return false, err } diff --git a/core/fpcheck/detectors/helpers.go b/core/fpcheck/detectors/helpers.go index 8a7a463..4de3dff 100644 --- a/core/fpcheck/detectors/helpers.go +++ b/core/fpcheck/detectors/helpers.go @@ -88,6 +88,13 @@ func classifyStatus(status string) bool { return false } + if strings.Contains(value, "🔴") { + return true + } + if strings.Contains(value, "🟢") || strings.Contains(value, "⚪") { + return false + } + notDetected := []string{"not detected", "not found", "clean", "clear", "pass", "passed", "ok", "safe", "green", "false", "no"} for _, marker := range notDetected { if strings.Contains(value, marker) { diff --git a/core/fpcheck/detectors/rebrowser.go b/core/fpcheck/detectors/rebrowser.go index d5d96c2..fe7eb62 100644 --- a/core/fpcheck/detectors/rebrowser.go +++ b/core/fpcheck/detectors/rebrowser.go @@ -2,7 +2,9 @@ package detectors import ( "context" + "encoding/json" "fmt" + "strings" "time" "github.com/go-rod/rod" @@ -25,6 +27,14 @@ func (Rebrowser) URL() string { return rebrowserURL } +type rebrowserCheck struct { + Type string `json:"type"` + Icon string `json:"icon"` + Rating float64 `json:"rating"` + Note string `json:"note"` + Debug string `json:"debug"` +} + func (Rebrowser) Extract(ctx context.Context, page *rod.Page) (map[string]fpcheck.Detection, string, error) { err := waitFor(ctx, 20*time.Second, 250*time.Millisecond, func() (bool, error) { hasBody, _, err := page.Has("body") @@ -36,8 +46,14 @@ func (Rebrowser) Extract(ctx context.Context, page *rod.Page) (map[string]fpchec } res, err := page.Eval(`() => { - const text = (document.body && document.body.innerText ? document.body.innerText : "").toLowerCase(); - return text.includes("runtimeenableleak") || text.includes("sourceurlleak") || text.includes("mainworldexecution") || text.includes("webdriver"); + const output = document.querySelector('#detections-json'); + if (!output || !output.value) return false; + try { + const parsed = JSON.parse(output.value); + return Array.isArray(parsed) && parsed.length > 0; + } catch (_) { + return false; + } }`) if err != nil { return false, nil @@ -53,25 +69,124 @@ func (Rebrowser) Extract(ctx context.Context, page *rod.Page) (map[string]fpchec return nil, "", fmt.Errorf("rebrowser readiness: %w", err) } - rows, err := parseRows(page) + res, err := page.Eval(`() => { + const normalize = (value) => (value || "").replace(/\s+/g, " ").trim(); + const stripHTML = (value) => { + const div = document.createElement('div'); + div.innerHTML = value || ''; + return normalize(div.textContent || div.innerText || ''); + }; + const checks = new Map(); + + const put = (item) => { + const type = normalize(item.type); + if (!type) return; + + const current = checks.get(type) || { type, icon: "", rating: 0, note: "", debug: "" }; + const next = { + type, + icon: normalize(item.icon || current.icon), + rating: Number.isFinite(item.rating) ? item.rating : current.rating, + note: normalize(item.note || current.note), + debug: normalize(item.debug || current.debug), + }; + checks.set(type, next); + }; + + try { + const raw = document.querySelector('#detections-json')?.value || '[]'; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + for (const item of parsed) { + put({ + type: item.type || '', + rating: Number(item.rating), + note: stripHTML(item.note || ''), + debug: typeof item.debug === 'string' ? normalize(item.debug) : normalize(JSON.stringify(item.debug || {})), + }); + } + } + } catch (_) {} + + for (const row of Array.from(document.querySelectorAll('#detections-table tbody tr'))) { + const cells = Array.from(row.querySelectorAll('td')); + if (cells.length < 1) continue; + const rawName = normalize(cells[0].innerText || cells[0].textContent || ""); + if (!rawName) continue; + const chars = Array.from(rawName); + const icon = chars.length > 0 ? chars[0] : ""; + const type = normalize(rawName.replace(icon, "")); + const note = cells.length > 2 ? normalize(cells[2].innerText || cells[2].textContent || "") : ""; + put({ + type, + icon, + note, + }); + } + + return Array.from(checks.values()); + }`) if err != nil { - return nil, "", err - } - if len(rows) == 0 { - return nil, "", fmt.Errorf("rebrowser detector rows not found") + return nil, "", fmt.Errorf("rebrowser extraction failed: %w", err) } - detections := rowsToDetections(rows, []string{ - "runtimeenableleak", - "sourceurlleak", - "mainworldexecution", - "webdriver", - "automation", - }) + var checks []rebrowserCheck + if err := res.Value.Unmarshal(&checks); err != nil { + return nil, "", fmt.Errorf("decode rebrowser checks: %w", err) + } + if len(checks) == 0 { + return nil, "", fmt.Errorf("rebrowser detector checks not found") + } + detections := rebrowserChecksToDetections(checks) if len(detections) == 0 { return nil, "", fmt.Errorf("rebrowser detections are empty") } - return detections, "", nil + raw, _ := json.MarshalIndent(checks, "", " ") + return detections, string(raw), nil +} + +func rebrowserChecksToDetections(checks []rebrowserCheck) map[string]fpcheck.Detection { + detections := make(map[string]fpcheck.Detection, len(checks)) + for _, check := range checks { + key := normalizeKey(check.Type) + if key == "unknown" { + continue + } + + detected := false + switch check.Icon { + case "🔴": + detected = true + case "🟢", "🟡", "⚪️", "⚪": + detected = false + default: + detected = check.Rating >= 1 + } + + description := strings.TrimSpace(check.Note) + if strings.TrimSpace(check.Debug) != "" { + if description != "" { + description = description + " | " + strings.TrimSpace(check.Debug) + } else { + description = strings.TrimSpace(check.Debug) + } + } + if description == "" { + description = fmt.Sprintf("rating=%.2f", check.Rating) + } + + severity := "" + if detected { + severity = "critical" + } + + detections[key] = fpcheck.Detection{ + Detected: detected, + Description: description, + Severity: severity, + } + } + return detections } diff --git a/core/fpcheck/detectors/rebrowser_test.go b/core/fpcheck/detectors/rebrowser_test.go new file mode 100644 index 0000000..d058ede --- /dev/null +++ b/core/fpcheck/detectors/rebrowser_test.go @@ -0,0 +1,38 @@ +package detectors + +import "testing" + +func TestRebrowserChecksToDetections_UsesIconAndRating(t *testing.T) { + checks := []rebrowserCheck{ + { + Type: "navigatorWebdriver", + Icon: "🔴", + Rating: -1, + Note: "Own properties detected", + }, + { + Type: "runtimeEnableLeak", + Icon: "", + Rating: 1, + Note: "runtime leak", + }, + { + Type: "viewport", + Icon: "🟢", + Rating: 1, + Note: "looks fine", + }, + } + + detections := rebrowserChecksToDetections(checks) + + if !detections["navigatorwebdriver"].Detected { + t.Fatal("expected red icon check to be detected") + } + if !detections["runtimeenableleak"].Detected { + t.Fatal("expected rating>=1 check to be detected") + } + if detections["viewport"].Detected { + t.Fatal("expected green icon check to be not detected") + } +} diff --git a/core/fpcheck/runner.go b/core/fpcheck/runner.go index 89dcd34..1457c28 100644 --- a/core/fpcheck/runner.go +++ b/core/fpcheck/runner.go @@ -13,23 +13,16 @@ import ( "github.com/go-rod/rod/lib/proto" ) -const ( - ModeStealthOn = "stealth-on" - ModeStealthOff = "stealth-off" -) - // RunOptions controls detector run behavior. type RunOptions struct { - UseStealth bool ArtifactDir string WaitBeforeClose time.Duration } // Run navigates the given browser to detector URL, extracts verdicts, // captures a screenshot artifact, and returns a normalized report. -func Run(ctx context.Context, browser BrowserNavigator, detector Detector, useStealth bool, artifactDir string) (Report, error) { +func Run(ctx context.Context, browser BrowserNavigator, detector Detector, artifactDir string) (Report, error) { return RunWithOptions(ctx, browser, detector, RunOptions{ - UseStealth: useStealth, ArtifactDir: artifactDir, }) } @@ -41,7 +34,6 @@ func RunWithOptions(ctx context.Context, browser BrowserNavigator, detector Dete report := Report{ DetectorName: detector.Name(), URL: detector.URL(), - UseStealth: options.UseStealth, Detections: map[string]Detection{}, } @@ -50,7 +42,7 @@ func RunWithOptions(ctx context.Context, browser BrowserNavigator, detector Dete artifactDir = "testdata" } - screenshotPath := filepath.Join(artifactDir, fmt.Sprintf("fpcheck_%s_%s.png", sanitizeFilePart(detector.Name()), modeLabel(options.UseStealth))) + screenshotPath := filepath.Join(artifactDir, fmt.Sprintf("fpcheck_%s.png", sanitizeFilePart(detector.Name()))) report.Screenshot = filepath.ToSlash(screenshotPath) page, err := browser.Navigate(ctx, detector.URL()) @@ -152,13 +144,6 @@ func closePageWithTimeout(ctx context.Context, page *rod.Page, timeout time.Dura } } -func modeLabel(useStealth bool) string { - if useStealth { - return ModeStealthOn - } - return ModeStealthOff -} - func sanitizeFilePart(value string) string { value = strings.TrimSpace(strings.ToLower(value)) if value == "" { diff --git a/core/profile_context.go b/core/profile_context.go new file mode 100644 index 0000000..174ea33 --- /dev/null +++ b/core/profile_context.go @@ -0,0 +1,28 @@ +package core + +import ( + "context" + "strings" +) + +type profileContextKey string + +const profileRegionContextKey profileContextKey = "profile_region" + +func WithProfileRegion(ctx context.Context, region string) context.Context { + region = strings.TrimSpace(region) + if region == "" { + return EnsureContext(ctx) + } + return context.WithValue(EnsureContext(ctx), profileRegionContextKey, region) +} + +func profileRegionFromContext(ctx context.Context) string { + value, _ := EnsureContext(ctx).Value(profileRegionContextKey).(string) + return strings.TrimSpace(value) +} + +func engineFromContext(ctx context.Context) string { + value, _ := EnsureContext(ctx).Value(engineContextKey).(string) + return strings.TrimSpace(value) +} diff --git a/core/server.go b/core/server.go index 0461c41..e8767f8 100644 --- a/core/server.go +++ b/core/server.go @@ -428,7 +428,6 @@ func (s *Server) handleFingerprintCheck(c *fiber.Ctx) error { reports := make([]fpcheck.Report, 0, len(req.detectors)) for idx, detector := range req.detectors { runOpts := fpcheck.RunOptions{ - UseStealth: req.browserOpts.UseStealth, ArtifactDir: artifactDir, } if req.waitMs > 0 && idx == len(req.detectors)-1 { @@ -466,11 +465,6 @@ func (s *Server) parseFingerprintCheckRequest(c *fiber.Ctx) (fingerprintCheckReq return fingerprintCheckRequest{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } - useStealth, err := parseOptionalBoolQuery(c.Query("stealth", ""), s.opts.FingerprintBrowserOpts.UseStealth) - if err != nil { - return fingerprintCheckRequest{}, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("invalid stealth query value: %v", err)) - } - headless, err := parseOptionalBoolQuery(c.Query("headless", ""), true) if err != nil { return fingerprintCheckRequest{}, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("invalid headless query value: %v", err)) @@ -491,7 +485,6 @@ func (s *Server) parseFingerprintCheckRequest(c *fiber.Ctx) (fingerprintCheckReq browserOpts := s.opts.FingerprintBrowserOpts browserOpts.IsHeadless = headless - browserOpts.UseStealth = useStealth browserOpts.Timeout = time.Duration(timeoutMs) * time.Millisecond browserOpts.LeavePageOpen = false browserOpts.UserAgent = strings.TrimSpace(c.Query("user_agent", browserOpts.UserAgent)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 316691f..7727384 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -186,14 +186,14 @@ Defaults below are the shipped defaults in `config.yaml` (if present). If the co ### `app` -| Key | Default | Description | -| ------------------ | ------- | ----------------------------- | -| `app.timeout` | `15` | Request timeout in seconds | -| `app.browser_path` | `""` | Custom browser binary path | -| `app.head` | `false` | Headful browser UI | -| `app.leakless` | `false` | Force browser process cleanup | -| `app.leave_head` | `false` | Keep browser tabs open | -| `app.stealth` | `false` | Enable stealth plugin | +| Key | Default | Description | +| ------------------ | ------- | ------------------------------ | +| `app.timeout` | `15` | Request timeout in seconds | +| `app.browser_path` | `""` | Custom browser binary path | +| `app.profiles` | `""` | Override browser profiles JSON | +| `app.head` | `false` | Headful browser UI | +| `app.leakless` | `false` | Force browser process cleanup | +| `app.leave_head` | `false` | Keep browser tabs open | ### `proxies` diff --git a/duckduckgo/search.go b/duckduckgo/search.go index fe06ae9..082276b 100644 --- a/duckduckgo/search.go +++ b/duckduckgo/search.go @@ -175,6 +175,7 @@ func (ddg *DuckDuckGo) parseResults(results rod.Elements, pageNum int) []core.Se // results. It may return core.ErrCaptcha or core.ErrSearchTimeout. func (ddg *DuckDuckGo) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) { ctx = core.WithEngine(core.EnsureContext(ctx), ddg.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *ddg scoped.logger = ddg.logger.WithRequest(ctx) @@ -298,6 +299,7 @@ func (ddg *DuckDuckGo) Search(ctx context.Context, query core.Query) (results [] // results. It may return core.ErrCaptcha or core.ErrSearchTimeout. func (ddg *DuckDuckGo) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) { ctx = core.WithEngine(core.EnsureContext(ctx), ddg.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *ddg scoped.logger = ddg.logger.WithRequest(ctx) diff --git a/go.mod b/go.mod index b2bb06d..655e1e0 100644 --- a/go.mod +++ b/go.mod @@ -7,25 +7,26 @@ toolchain go1.24.6 require ( github.com/2captcha/2captcha-go v1.1.10 github.com/PuerkitoBio/goquery v1.10.3 + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/corpix/uarand v0.2.0 github.com/go-rod/rod v0.116.2 - github.com/go-rod/stealth v0.4.9 github.com/gofiber/fiber/v2 v2.52.9 + github.com/google/uuid v1.6.0 github.com/refraction-networking/utls v1.8.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.7 github.com/spf13/viper v1.20.1 + github.com/ysmood/gson v0.7.3 + golang.org/x/net v0.43.0 golang.org/x/time v0.12.0 ) require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -44,10 +45,8 @@ require ( github.com/ysmood/fetchup v0.3.0 // indirect github.com/ysmood/goob v0.4.0 // indirect github.com/ysmood/got v0.41.0 // indirect - github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index 0a3c1bf..9f4ce54 100644 --- a/go.sum +++ b/go.sum @@ -18,11 +18,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= -github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4= -github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= @@ -89,22 +86,18 @@ github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Y github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= github.com/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8= github.com/ysmood/fetchup v0.3.0/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A= github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= -github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= -github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM= github.com/ysmood/got v0.41.0 h1:XiFH311ltTSGyxjeKcNvy7dzbJjjTzn6DBgK313JHBs= github.com/ysmood/got v0.41.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= -github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/google/search.go b/google/search.go index a6c1767..a7231bd 100644 --- a/google/search.go +++ b/google/search.go @@ -174,6 +174,7 @@ func (gogl *Google) acceptCookies(page *rod.Page) { // It may return core.ErrCaptcha or core.ErrSearchTimeout. func (gogl *Google) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) { ctx = core.WithEngine(core.EnsureContext(ctx), gogl.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *gogl scoped.logger = gogl.logger.WithRequest(ctx) @@ -427,6 +428,7 @@ func (gogl *Google) Search(ctx context.Context, query core.Query) (results []cor // results. It may return core.ErrCaptcha or core.ErrSearchTimeout. func (gogl *Google) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) { ctx = core.WithEngine(core.EnsureContext(ctx), gogl.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *gogl scoped.logger = gogl.logger.WithRequest(ctx) diff --git a/yandex/search.go b/yandex/search.go index 2881490..2756a4f 100644 --- a/yandex/search.go +++ b/yandex/search.go @@ -128,6 +128,7 @@ func (yand *Yandex) parseResults(results rod.Elements, pageNum int) []core.Searc // It may return core.ErrCaptcha or core.ErrSearchTimeout. func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []core.SearchResult, err error) { ctx = core.WithEngine(core.EnsureContext(ctx), yand.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *yand scoped.logger = yand.logger.WithRequest(ctx) @@ -227,6 +228,7 @@ func (yand *Yandex) Search(ctx context.Context, query core.Query) (results []cor // results. It may return core.ErrCaptcha or core.ErrSearchTimeout. func (yand *Yandex) SearchImage(ctx context.Context, query core.Query) ([]core.SearchResult, error) { ctx = core.WithEngine(core.EnsureContext(ctx), yand.Name()) + ctx = core.WithProfileRegion(ctx, query.LangCode) ctx = core.WithQueryHash(ctx, core.QueryHashFromQuery(query)) scoped := *yand scoped.logger = yand.logger.WithRequest(ctx)