package browser import ( _ "embed" "encoding/json" "fmt" "hash/fnv" "os" "runtime" "slices" "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"` UserAgentTemplate string `json:"user_agent_template"` UserAgent string `json:"user_agent,omitempty"` UACHBrands []BrandVersion `json:"uach_brands,omitempty"` UACHFullVerList []BrandVersion `json:"uach_full_version_list,omitempty"` 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"` Tags []string `json:"tags"` Weight int `json:"weight"` } 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-uhd620" ProfileChromeMacUS = "chrome-macos-intel-iris" ProfileChromeLinuxUS = "chrome-linux-mesa-uhd620" ) //go:embed profiles.json var defaultProfilesJSON []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.UserAgentTemplate) == "" && strings.TrimSpace(profile.UserAgent) == "" { return fmt.Errorf("profiles[%d] has no user_agent_template or user_agent", 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 } // SelectProfile returns a deterministic profile for the given engine and region. // It respects lane_profile_ids overrides and falls back to the OS-preferred default. // Used by tests and single-instance callers; internally delegates to SelectProfileForSession with empty salt. func SelectProfile(engine string, region string) Profile { return SelectProfileForSession(engine, region, "") } // SelectProfileForSession picks a profile for (engine, region, salt). // If a lane_profile_ids override exists it is always honoured. // Empty salt picks the first eligible profile (same as SelectProfile). // Non-empty salt uses weighted selection seeded by FNV-1a hash of salt, // giving each session a stable but varied profile. func SelectProfileForSession(engine, region, salt string) Profile { return SelectProfileForSessionHeadless(engine, region, salt, false) } // SelectProfileForSessionHeadless is SelectProfileForSession, but headless Linux // (the Docker deployment) renders WebGL via SwiftShader, so it only picks // swiftshader-tagged profiles. Runtimes with a real GPU exclude them. func SelectProfileForSessionHeadless(engine, region, salt string, headless bool) 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) } pool := eligibleProfiles(runtime.GOOS, headless) return pickWeighted(pool, salt) } type weightedProfile struct { profile Profile weight int } // eligibleProfiles builds the pool for the runtime platform. Headless Linux // keeps only swiftshader-tagged profiles (SwiftShader WebGL); a real GPU // excludes them. We no longer spoof WebGL, so the GPU sub-tag (nvidia/amd/mesa) // only steers selection - it matches reality on Docker, cosmetic on a headful box. func eligibleProfiles(goos string, headless bool) []weightedProfile { profileCatalogMu.RLock() snap := make([]Profile, 0, len(catalog)) for _, p := range catalog { snap = append(snap, p) } profileCatalogMu.RUnlock() // Stable ordering so empty-salt picks are deterministic across map iterations. slices.SortFunc(snap, func(a, b Profile) int { return strings.Compare(a.ID, b.ID) }) platformTag := runtimePlatformTag(goos) wantSwiftShader := headless && goos == "linux" var pool []weightedProfile for _, p := range snap { if platformTag != "" && !slices.Contains(p.Tags, platformTag) { continue } if slices.Contains(p.Tags, "swiftshader") != wantSwiftShader { continue } w := p.Weight if w <= 0 { w = 1 } pool = append(pool, weightedProfile{profile: p, weight: w}) } // Fall back to the platform pool if no swiftshader profile exists yet, so // selection never returns empty. if len(pool) == 0 && wantSwiftShader { return eligibleProfiles(goos, false) } return pool } func runtimePlatformTag(goos string) string { switch strings.ToLower(strings.TrimSpace(goos)) { case "windows": return "windows" case "darwin": return "macos" case "linux": return "linux" default: return "" } } // pickWeighted selects a profile from pool using FNV-1a hash of salt modulo total weight. // Empty salt returns the first profile in the pool (deterministic for tests). func pickWeighted(pool []weightedProfile, salt string) Profile { if len(pool) == 0 { return profileByID(defaultProfileID()) } if salt == "" { return pool[0].profile } total := 0 for _, wp := range pool { total += wp.weight } if total <= 0 { return pool[0].profile } h := fnv.New32a() _, _ = h.Write([]byte(salt)) idx := int(h.Sum32()) % total cumulative := 0 for _, wp := range pool { cumulative += wp.weight if idx < cumulative { return wp.profile } } return pool[len(pool)-1].profile } 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" } } // ProfileByID looks up a profile by exact ID. Returns (profile, true) when found, // (zero, false) when the ID is not in the catalog. Unlike the internal profileByID, // it does not fall back to a default; the caller decides what to do on miss. func ProfileByID(profileID string) (Profile, bool) { profileCatalogMu.RLock() defer profileCatalogMu.RUnlock() profile, ok := catalog[strings.TrimSpace(profileID)] return profile, ok } func profileByID(profileID string) Profile { profileCatalogMu.RLock() defer profileCatalogMu.RUnlock() if profile, ok := catalog[profileID]; ok { return profile } if fallback, ok := catalog[defaultProfileID()]; ok { return fallback } for _, profile := range catalog { return profile } return Profile{} } func defaultProfileID() string { switch runtime.GOOS { case "windows": return ProfileChromeWinUS case "darwin": return ProfileChromeMacUS default: return ProfileChromeLinuxUS } }