diff --git a/core/locale.go b/core/locale.go index 77a4673..3b2c11a 100644 --- a/core/locale.go +++ b/core/locale.go @@ -1,15 +1,11 @@ package core -import "strings" +import "github.com/karust/openserp/core/region" // Locale is a parsed language/region pair derived from a BCP47-style code. -// Language is the lowercase 2-letter language subtag (e.g. "en", "de"). -// Country is the uppercase 2-letter region subtag (e.g. "US", "DE"); it may be -// empty when the input had no region and the caller did not request a default. -type Locale struct { - Language string - Country string -} +// The canonical definition lives in the dependency-free core/region subpackage; +// this alias preserves the historical core.Locale name for existing callers. +type Locale = region.Locale var defaultLocaleCountryByLanguage = map[string]string{ "en": "US", @@ -29,50 +25,15 @@ var defaultLocaleCountryByLanguage = map[string]string{ } // ParseLocale parses a language code such as "en", "EN-us", or "de_AT" into a -// Locale. Returns the zero value when the input is empty or has no language -// subtag. Country is uppercased; Language is lowercased. +// Locale. See region.ParseLocale for details. func ParseLocale(code string) Locale { - code = strings.TrimSpace(code) - if code == "" { - return Locale{} - } - code = strings.ReplaceAll(code, "_", "-") - - parts := strings.Split(code, "-") - language := strings.ToLower(strings.TrimSpace(parts[0])) - if language == "" { - return Locale{} - } - - country := "" - if len(parts) > 1 { - country = strings.ToUpper(strings.TrimSpace(parts[1])) - } - return Locale{Language: language, Country: country} + return region.ParseLocale(code) } // CountryFromRegion extracts a two-letter country/market code from a region -// hint. It accepts "ru", "RU", "en-RU", and "en_RU"; engine-native numeric -// region IDs intentionally return empty here. -func CountryFromRegion(region string) string { - region = strings.TrimSpace(region) - if region == "" { - return "" - } - region = strings.ReplaceAll(region, "_", "-") - if len(region) == 2 && isAlphaASCII(region[0]) && isAlphaASCII(region[1]) { - return strings.ToUpper(region) - } - - locale := ParseLocale(region) - if len(locale.Country) == 2 && isAlphaASCII(locale.Country[0]) && isAlphaASCII(locale.Country[1]) { - return locale.Country - } - return "" -} - -func isAlphaASCII(ch byte) bool { - return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') +// hint. See region.CountryFromRegion for details. +func CountryFromRegion(hint string) string { + return region.CountryFromRegion(hint) } // PrimaryLanguageTag returns the BCP47 primary tag for a lang code, filling in diff --git a/core/region.go b/core/region.go index 8f5e2ed..272cb62 100644 --- a/core/region.go +++ b/core/region.go @@ -1,174 +1,18 @@ package core -import "strings" +import "github.com/karust/openserp/core/region" + +// Region resolution lives in the dependency-free github.com/karust/openserp/core/region +// subpackage so lightweight consumers that only need geotargeting can import it +// without pulling in core's browser/scraping dependencies. The aliases and +// wrappers below preserve the historical core.* API for existing callers. // RegionTarget is the resolved, per-engine targeting for a user-supplied region -// hint. Engines read the field relevant to them: Google uses GoogleCanonical to -// build a UULE, Yandex uses YandexLR. Country is the ISO 3166-1 alpha-2 code -// when one could be derived, useful as a coarse market signal. -// -// A field left empty means "no better signal than the raw input" — callers -// should fall back to their previous behavior (e.g. gl= from locale, or -// dropping the parameter entirely). Resolution never fails: an unrecognized -// region simply yields empty engine fields rather than an error. -type RegionTarget struct { - // Raw is the trimmed original input, preserved for engines that pass it - // through (e.g. Yandex numeric lr IDs). - Raw string - // Country is the ISO 3166-1 alpha-2 code (uppercase) when derivable, else "". - Country string - // GoogleCanonical is the exact Google geotargets canonical location name - // (e.g. "Berlin,Berlin,Germany") suitable for UULE v1 encoding, else "". - GoogleCanonical string - // YandexLR is the Yandex lr region ID (e.g. "213"), else "". - YandexLR string -} - -// yandexLRByCountry maps an ISO country code to a Yandex lr region ID. Yandex -// only exposes a limited set of country-level regions; cities require numeric -// lr IDs passed through verbatim. -var yandexLRByCountry = map[string]string{ - "AT": "113", "AU": "211", "BE": "114", "BR": "94", "CA": "95", - "CH": "126", "DE": "96", "DK": "203", "ES": "204", "FI": "123", - "FR": "124", "GB": "102", "IE": "10063", "IN": "994", "IT": "205", - "JP": "137", "KR": "135", "MX": "20271", "NL": "118", "NO": "119", - "PL": "120", "RU": "225", "SE": "127", "SG": "10105", "TR": "983", - "UA": "187", "UK": "102", "US": "84", "ZA": "10021", -} - -// cityCanonical maps a normalized bare city name to its exact Google geotargets -// canonical name (used to build a UULE). Only UULE-bearing city targeting needs -// a name table — country/state targeting rides on gl= and never needs one. -// -// The list is deliberately small and hand-curated: bare city names are -// ambiguous (e.g. "London" exists in CA/GB/US), so we only auto-resolve a -// prominence list to its "obvious" match. Canonical names below are copied -// verbatim from Google's geotargets data; any other city can still be targeted -// by passing its full canonical name ("City,Region,Country") directly. -var cityCanonical = map[string]string{ - "amsterdam": "Amsterdam,North Holland,Netherlands", - "athens": "Athens,Athens,Attica,Greece", - "austin": "Austin,Texas,United States", - "bangalore": "Bengaluru,Karnataka,India", - "barcelona": "Barcelona,Barcelona,Catalonia,Spain", - "beijing": "Beijing,Beijing,China", - "berlin": "Berlin,Berlin,Germany", - "birmingham": "Birmingham,West Midlands,England,United Kingdom", - "boston": "Boston,Massachusetts,United States", - "brussels": "Brussels,Brussels,Belgium", - "buenos aires": "Buenos Aires,Buenos Aires,Argentina", - "cairo": "Cairo,Cairo Governorate,Egypt", - "chicago": "Chicago,Illinois,United States", - "copenhagen": "Copenhagen,Capital Region of Denmark,Denmark", - "dallas": "Dallas,Texas,United States", - "delhi": "Delhi,Delhi,India", - "dubai": "Dubai,Dubai,United Arab Emirates", - "dublin": "Dublin,County Dublin,Ireland", - "frankfurt": "Frankfurt am Main,Hessen,Germany", - "hamburg": "Hamburg,Hamburg,Germany", - "helsinki": "Helsinki,Helsinki,Uusimaa,Finland", - "hong kong": "Hong Kong,Hong Kong", - "istanbul": "Istanbul,Istanbul,Turkiye", - "johannesburg": "Johannesburg,Gauteng,South Africa", - "kyiv": "Kyiv,Kyiv city,Ukraine", - "lisbon": "Lisbon,Lisbon,Lisbon,Portugal", - "london": "London,England,United Kingdom", - "los angeles": "Los Angeles,California,United States", - "lyon": "Lyon,Auvergne-Rhone-Alpes,France", - "madrid": "Madrid,Community of Madrid,Spain", - "manchester": "Manchester,England,United Kingdom", - "marseille": "Marseille,Provence-Alpes-Cote d'Azur,France", - "melbourne": "Melbourne,Victoria,Australia", - "mexico city": "Mexico City,Mexico City,Mexico", - "miami": "Miami,Florida,United States", - "milan": "Milan,Milan,Lombardy,Italy", - "montreal": "Montreal,Montreal,Quebec,Canada", - "moscow": "Moscow,Moscow,Russia", - "mumbai": "Mumbai,Maharashtra,India", - "munich": "Munich,Bavaria,Germany", - "new york": "New York,New York,United States", - "osaka": "Osaka,Osaka,Japan", - "oslo": "Oslo,Oslo,Norway", - "paris": "Paris,Paris,Ile-de-France,France", - "prague": "Prague,Prague,Czechia", - "rio de janeiro": "Rio de Janeiro,State of Rio de Janeiro,Brazil", - "rome": "Rome,Rome,Lazio,Italy", - "san francisco": "San Francisco,California,United States", - "sao paulo": "Sao Paulo,State of Sao Paulo,Brazil", - "seattle": "Seattle,Washington,United States", - "seoul": "Seoul,Seoul,South Korea", - "shanghai": "Shanghai,Shanghai,China", - "singapore": "Singapore,Singapore", - "stockholm": "Stockholm,Stockholm County,Sweden", - "sydney": "Sydney,New South Wales,Australia", - "tokyo": "Tokyo,Tokyo,Japan", - "toronto": "Toronto,Toronto,Ontario,Canada", - "vancouver": "Vancouver,British Columbia,Canada", - "vienna": "Vienna,Vienna,Vienna,Austria", - "warsaw": "Warsaw,Warsaw,Masovian Voivodeship,Poland", - "washington": "Washington,District of Columbia,United States", - "zurich": "Zurich,Zurich,Switzerland", -} - -// ResolveRegion turns a free-text region hint into per-engine targeting. It -// never errors: unrecognized input yields a RegionTarget with empty engine -// fields, leaving callers to fall back to their defaults. -// -// Accepted inputs, in priority order: -// - Numeric (e.g. "213"): a Yandex-native lr ID. Passed through as YandexLR. -// - A 2-letter country or BCP47-style locale (e.g. "DE", "en-GB"): resolved to -// a country and its Yandex lr. No Google canonical — country targeting rides -// on gl=, not UULE. -// - A bare curated city name (e.g. "Berlin"): resolved to its canonical name. -// - A full "City,Region,Country" canonical name typed verbatim (>=2 commas): -// passed through to Google as-is. -func ResolveRegion(region string) RegionTarget { - region = strings.TrimSpace(region) - t := RegionTarget{Raw: region} - if region == "" { - return t - } - - // Yandex-native numeric lr IDs: pass through, nothing else to derive. - if isDigitsOnly(region) { - t.YandexLR = region - return t - } - - // Country / locale code (e.g. "DE", "en-GB"). Country-level targeting is - // conveyed via Country (Google uses gl=, Yandex the country lr); we - // deliberately do NOT emit a Google canonical/UULE for a whole country. - if cc := CountryFromRegion(region); cc != "" { - t.Country = cc - t.YandexLR = yandexLRByCountry[cc] - return t - } - - // Bare curated city name (e.g. "Berlin", "New York"). - if c := cityCanonical[strings.ToLower(region)]; c != "" { - t.GoogleCanonical = c - return t - } - - // Looks like a full "City,Region,Country" canonical name the caller typed - // verbatim (>=2 commas). Pass it through to Google as-is; Google ignores it - // if it isn't a real canonical name, which is the caller's responsibility. - if strings.Count(region, ",") >= 2 { - t.GoogleCanonical = region - return t - } - - return t -} - -func isDigitsOnly(s string) bool { - if s == "" { - return false - } - for _, r := range s { - if r < '0' || r > '9' { - return false - } - } - return true +// hint. See region.RegionTarget for details. +type RegionTarget = region.RegionTarget + +// ResolveRegion turns a free-text region hint into per-engine targeting. +// See region.ResolveRegion for accepted inputs and semantics. +func ResolveRegion(hint string) RegionTarget { + return region.ResolveRegion(hint) } diff --git a/core/region/region.go b/core/region/region.go new file mode 100644 index 0000000..f73e3e5 --- /dev/null +++ b/core/region/region.go @@ -0,0 +1,287 @@ +// Package region resolves a free-text region hint into per-engine search +// targeting (Google UULE canonical names, Yandex lr IDs, ISO country codes). +// +// It is deliberately dependency-free (standard library only) so lightweight +// consumers that only need geotargeting can import it without pulling in the +// headless-browser/scraping stack the rest of github.com/karust/openserp/core +// depends on. The parent core package re-exports these symbols for backward +// compatibility. +package region + +import ( + "encoding/base64" + "strings" +) + +// Locale is a parsed language/region pair derived from a BCP47-style code. +// Language is the lowercase 2-letter language subtag (e.g. "en", "de"). +// Country is the uppercase 2-letter region subtag (e.g. "US", "DE"); it may be +// empty when the input had no region and the caller did not request a default. +type Locale struct { + Language string + Country string +} + +// ParseLocale parses a language code such as "en", "EN-us", or "de_AT" into a +// Locale. Returns the zero value when the input is empty or has no language +// subtag. Country is uppercased; Language is lowercased. +func ParseLocale(code string) Locale { + code = strings.TrimSpace(code) + if code == "" { + return Locale{} + } + code = strings.ReplaceAll(code, "_", "-") + + parts := strings.Split(code, "-") + language := strings.ToLower(strings.TrimSpace(parts[0])) + if language == "" { + return Locale{} + } + + country := "" + if len(parts) > 1 { + country = strings.ToUpper(strings.TrimSpace(parts[1])) + } + return Locale{Language: language, Country: country} +} + +// CountryFromRegion extracts a two-letter country/market code from a region +// hint. It accepts "ru", "RU", "en-RU", and "en_RU"; engine-native numeric +// region IDs intentionally return empty here. +func CountryFromRegion(region string) string { + region = strings.TrimSpace(region) + if region == "" { + return "" + } + region = strings.ReplaceAll(region, "_", "-") + if len(region) == 2 && isAlphaASCII(region[0]) && isAlphaASCII(region[1]) { + return strings.ToUpper(region) + } + + locale := ParseLocale(region) + if len(locale.Country) == 2 && isAlphaASCII(locale.Country[0]) && isAlphaASCII(locale.Country[1]) { + return locale.Country + } + return "" +} + +func isAlphaASCII(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') +} + +// RegionTarget is the resolved, per-engine targeting for a user-supplied region +// hint. Engines read the field relevant to them: Google uses GoogleCanonical to +// build a UULE, Yandex uses YandexLR. Country is the ISO 3166-1 alpha-2 code +// when one could be derived, useful as a coarse market signal. +// +// A field left empty means "no better signal than the raw input" — callers +// should fall back to their previous behavior (e.g. gl= from locale, or +// dropping the parameter entirely). Resolution never fails: an unrecognized +// region simply yields empty engine fields rather than an error. +type RegionTarget struct { + // Raw is the trimmed original input, preserved for engines that pass it + // through (e.g. Yandex numeric lr IDs). + Raw string + // Country is the ISO 3166-1 alpha-2 code (uppercase) when derivable, else "". + Country string + // GoogleCanonical is the exact Google geotargets canonical location name + // (e.g. "Berlin,Berlin,Germany") suitable for UULE v1 encoding, else "". + GoogleCanonical string + // YandexLR is the Yandex lr region ID (e.g. "213"), else "". + YandexLR string +} + +// yandexLRByCountry maps an ISO country code to a Yandex lr region ID. Yandex +// only exposes a limited set of country-level regions; cities require numeric +// lr IDs passed through verbatim. +var yandexLRByCountry = map[string]string{ + "AT": "113", "AU": "211", "BE": "114", "BR": "94", "CA": "95", + "CH": "126", "DE": "96", "DK": "203", "ES": "204", "FI": "123", + "FR": "124", "GB": "102", "IE": "10063", "IN": "994", "IT": "205", + "JP": "137", "KR": "135", "MX": "20271", "NL": "118", "NO": "119", + "PL": "120", "RU": "225", "SE": "127", "SG": "10105", "TR": "983", + "UA": "187", "UK": "102", "US": "84", "ZA": "10021", +} + +// cityCanonical maps a normalized bare city name to its exact Google geotargets +// canonical name (used to build a UULE). Only UULE-bearing city targeting needs +// a name table — country/state targeting rides on gl= and never needs one. +// +// The list is deliberately small and hand-curated: bare city names are +// ambiguous (e.g. "London" exists in CA/GB/US), so we only auto-resolve a +// prominence list to its "obvious" match. Canonical names below are copied +// verbatim from Google's geotargets data; any other city can still be targeted +// by passing its full canonical name ("City,Region,Country") directly. +var cityCanonical = map[string]string{ + "amsterdam": "Amsterdam,North Holland,Netherlands", + "athens": "Athens,Athens,Attica,Greece", + "austin": "Austin,Texas,United States", + "bangalore": "Bengaluru,Karnataka,India", + "barcelona": "Barcelona,Barcelona,Catalonia,Spain", + "beijing": "Beijing,Beijing,China", + "berlin": "Berlin,Berlin,Germany", + "birmingham": "Birmingham,West Midlands,England,United Kingdom", + "boston": "Boston,Massachusetts,United States", + "brussels": "Brussels,Brussels,Belgium", + "buenos aires": "Buenos Aires,Buenos Aires,Argentina", + "cairo": "Cairo,Cairo Governorate,Egypt", + "chicago": "Chicago,Illinois,United States", + "copenhagen": "Copenhagen,Capital Region of Denmark,Denmark", + "dallas": "Dallas,Texas,United States", + "delhi": "Delhi,Delhi,India", + "dubai": "Dubai,Dubai,United Arab Emirates", + "dublin": "Dublin,County Dublin,Ireland", + "frankfurt": "Frankfurt am Main,Hessen,Germany", + "hamburg": "Hamburg,Hamburg,Germany", + "helsinki": "Helsinki,Helsinki,Uusimaa,Finland", + "hong kong": "Hong Kong,Hong Kong", + "istanbul": "Istanbul,Istanbul,Turkiye", + "johannesburg": "Johannesburg,Gauteng,South Africa", + "kyiv": "Kyiv,Kyiv city,Ukraine", + "lisbon": "Lisbon,Lisbon,Lisbon,Portugal", + "london": "London,England,United Kingdom", + "los angeles": "Los Angeles,California,United States", + "lyon": "Lyon,Auvergne-Rhone-Alpes,France", + "madrid": "Madrid,Community of Madrid,Spain", + "manchester": "Manchester,England,United Kingdom", + "marseille": "Marseille,Provence-Alpes-Cote d'Azur,France", + "melbourne": "Melbourne,Victoria,Australia", + "mexico city": "Mexico City,Mexico City,Mexico", + "miami": "Miami,Florida,United States", + "milan": "Milan,Milan,Lombardy,Italy", + "montreal": "Montreal,Montreal,Quebec,Canada", + "moscow": "Moscow,Moscow,Russia", + "mumbai": "Mumbai,Maharashtra,India", + "munich": "Munich,Bavaria,Germany", + "new york": "New York,New York,United States", + "osaka": "Osaka,Osaka,Japan", + "oslo": "Oslo,Oslo,Norway", + "paris": "Paris,Paris,Ile-de-France,France", + "prague": "Prague,Prague,Czechia", + "rio de janeiro": "Rio de Janeiro,State of Rio de Janeiro,Brazil", + "rome": "Rome,Rome,Lazio,Italy", + "san francisco": "San Francisco,California,United States", + "sao paulo": "Sao Paulo,State of Sao Paulo,Brazil", + "seattle": "Seattle,Washington,United States", + "seoul": "Seoul,Seoul,South Korea", + "shanghai": "Shanghai,Shanghai,China", + "singapore": "Singapore,Singapore", + "stockholm": "Stockholm,Stockholm County,Sweden", + "sydney": "Sydney,New South Wales,Australia", + "tokyo": "Tokyo,Tokyo,Japan", + "toronto": "Toronto,Toronto,Ontario,Canada", + "vancouver": "Vancouver,British Columbia,Canada", + "vienna": "Vienna,Vienna,Vienna,Austria", + "warsaw": "Warsaw,Warsaw,Masovian Voivodeship,Poland", + "washington": "Washington,District of Columbia,United States", + "zurich": "Zurich,Zurich,Switzerland", +} + +// ResolveRegion turns a free-text region hint into per-engine targeting. It +// never errors: unrecognized input yields a RegionTarget with empty engine +// fields, leaving callers to fall back to their defaults. +// +// Accepted inputs, in priority order: +// - Numeric (e.g. "213"): a Yandex-native lr ID. Passed through as YandexLR. +// - A 2-letter country or BCP47-style locale (e.g. "DE", "en-GB"): resolved to +// a country and its Yandex lr. No Google canonical — country targeting rides +// on gl=, not UULE. +// - A bare curated city name (e.g. "Berlin"): resolved to its canonical name. +// - A full "City,Region,Country" canonical name typed verbatim (>=2 commas): +// passed through to Google as-is. +func ResolveRegion(region string) RegionTarget { + region = strings.TrimSpace(region) + t := RegionTarget{Raw: region} + if region == "" { + return t + } + + // Yandex-native numeric lr IDs: pass through, nothing else to derive. + if isDigitsOnly(region) { + t.YandexLR = region + return t + } + + // Country / locale code (e.g. "DE", "en-GB"). Country-level targeting is + // conveyed via Country (Google uses gl=, Yandex the country lr); we + // deliberately do NOT emit a Google canonical/UULE for a whole country. + if cc := CountryFromRegion(region); cc != "" { + t.Country = cc + t.YandexLR = yandexLRByCountry[cc] + return t + } + + // Bare curated city name (e.g. "Berlin", "New York"). + if c := cityCanonical[strings.ToLower(region)]; c != "" { + t.GoogleCanonical = c + return t + } + + // Looks like a full "City,Region,Country" canonical name the caller typed + // verbatim (>=2 commas). Pass it through to Google as-is; Google ignores it + // if it isn't a real canonical name, which is the caller's responsibility. + if strings.Count(region, ",") >= 2 { + t.GoogleCanonical = region + return t + } + + return t +} + +func isDigitsOnly(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +const googleUULEPrefix = "w+CAIQICI" + +var googleUULELengthAlphabet = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") + +// GoogleUULE builds a Google UULE v1 value for a region hint, returning "" when +// no UULE applies. +// +// UULE only takes effect when the encoded string is an exact Google geotargets +// canonical name; a bare free-text city is silently ignored by Google. We +// therefore resolve the region first and encode the resolved canonical name. +// Country-level hints (e.g. "DE") and Yandex numeric lr IDs intentionally +// produce no UULE — those are conveyed via gl= or are not applicable to Google. +// +// As a best-effort fallback, a free-text region that resolves to neither a +// country, a numeric lr, nor a known canonical name is encoded verbatim; Google +// ignores it if it is not a real canonical name, matching prior behavior. +func GoogleUULE(region string) string { + target := ResolveRegion(region) + canonical := target.GoogleCanonical + if canonical == "" { + if target.Country != "" || target.YandexLR != "" { + return "" + } + canonical = strings.TrimSpace(region) + } + return EncodeGoogleUULE(canonical) +} + +// EncodeGoogleUULE encodes a canonical location name into a UULE v1 value: +// a fixed prefix, a single length-tag character, then base64(name). Returns "" +// for an empty name or one too long for the length alphabet. +func EncodeGoogleUULE(canonical string) string { + length := len([]rune(canonical)) + if length <= 0 || length >= len(googleUULELengthAlphabet) { + return "" + } + return googleUULEPrefix + string(googleUULELengthAlphabet[length]) + base64.StdEncoding.EncodeToString([]byte(canonical)) +} + +// YandexLR converts a public region hint to a Yandex lr ID, returning "" when +// none applies. Numeric input is passed through; a 2-letter country or locale +// is mapped via the country table. +func YandexLR(region string) string { + return ResolveRegion(region).YandexLR +} diff --git a/core/region/region_test.go b/core/region/region_test.go new file mode 100644 index 0000000..f859b32 --- /dev/null +++ b/core/region/region_test.go @@ -0,0 +1,112 @@ +package region + +import "testing" + +func TestResolveRegion(t *testing.T) { + tests := []struct { + name string + region string + country string + canonical string + lr string + }{ + { + name: "bare curated city resolves to canonical name", + region: "Berlin", + canonical: "Berlin,Berlin,Germany", + }, + { + name: "curated city is case-insensitive", + region: " berlin ", + canonical: "Berlin,Berlin,Germany", + }, + { + name: "ambiguous curated city picks obvious match", + region: "London", + canonical: "London,England,United Kingdom", + }, + { + name: "full canonical name passes through verbatim", + region: "Smalltown,Some Region,Faraway", + canonical: "Smalltown,Some Region,Faraway", + }, + { + name: "bare country code sets country and yandex lr but no uule", + region: "DE", + country: "DE", + lr: "96", + }, + { + name: "locale code resolves country", + region: "en-GB", + country: "GB", + lr: "102", + }, + { + name: "numeric region is a yandex lr passthrough", + region: "213", + lr: "213", + }, + { + name: "empty region resolves to nothing", + region: "", + }, + { + name: "unknown bare name resolves to nothing", + region: "Nowhereville", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveRegion(tt.region) + if got.Country != tt.country { + t.Errorf("Country = %q, want %q", got.Country, tt.country) + } + if got.GoogleCanonical != tt.canonical { + t.Errorf("GoogleCanonical = %q, want %q", got.GoogleCanonical, tt.canonical) + } + if got.YandexLR != tt.lr { + t.Errorf("YandexLR = %q, want %q", got.YandexLR, tt.lr) + } + }) + } +} + +func TestGoogleUULE(t *testing.T) { + // A curated bare city must encode its canonical name, not the raw input — + // Google ignores a UULE whose decoded value is not a real geotargets name. + if got := GoogleUULE("Berlin"); got != EncodeGoogleUULE("Berlin,Berlin,Germany") { + t.Errorf("GoogleUULE(Berlin) = %q, want canonical encoding", got) + } + // Country codes and numeric lr IDs ride on gl=/lr=, never UULE. + if got := GoogleUULE("DE"); got != "" { + t.Errorf("GoogleUULE(DE) = %q, want empty", got) + } + if got := GoogleUULE("213"); got != "" { + t.Errorf("GoogleUULE(213) = %q, want empty", got) + } + if got := GoogleUULE(""); got != "" { + t.Errorf("GoogleUULE(empty) = %q, want empty", got) + } +} + +func TestYandexLR(t *testing.T) { + cases := map[string]string{ + "213": "213", + " 213 ": "213", + "RU": "225", + "de": "96", + "en-RU": "225", + "en_GB": "102", + "UK": "102", + "ZZ": "", + "21a": "", + "": "", + } + for in, want := range cases { + if got := YandexLR(in); got != want { + t.Errorf("YandexLR(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/core/region_test.go b/core/region_test.go index e023e46..31a15ac 100644 --- a/core/region_test.go +++ b/core/region_test.go @@ -2,73 +2,17 @@ package core import "testing" -func TestResolveRegion(t *testing.T) { - tests := []struct { - name string - region string - country string - canonical string - lr string - }{ - { - name: "bare curated city resolves to canonical name", - region: "Berlin", - canonical: "Berlin,Berlin,Germany", - }, - { - name: "curated city is case-insensitive", - region: " berlin ", - canonical: "Berlin,Berlin,Germany", - }, - { - name: "ambiguous curated city picks obvious match", - region: "London", - canonical: "London,England,United Kingdom", - }, - { - name: "full canonical name passes through verbatim", - region: "Smalltown,Some Region,Faraway", - canonical: "Smalltown,Some Region,Faraway", - }, - { - name: "bare country code sets country and yandex lr but no uule", - region: "DE", - country: "DE", - lr: "96", - }, - { - name: "locale code resolves country", - region: "en-GB", - country: "GB", - lr: "102", - }, - { - name: "numeric region is a yandex lr passthrough", - region: "213", - lr: "213", - }, - { - name: "empty region resolves to nothing", - region: "", - }, - { - name: "unknown bare name resolves to nothing", - region: "Nowhereville", - }, +// The full ResolveRegion behavior is exercised in the core/region subpackage. +// This test only verifies the core.* re-export wiring stays intact. +func TestResolveRegionReExport(t *testing.T) { + got := ResolveRegion("Berlin") + if got.GoogleCanonical != "Berlin,Berlin,Germany" { + t.Errorf("GoogleCanonical = %q, want %q", got.GoogleCanonical, "Berlin,Berlin,Germany") } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ResolveRegion(tt.region) - if got.Country != tt.country { - t.Errorf("Country = %q, want %q", got.Country, tt.country) - } - if got.GoogleCanonical != tt.canonical { - t.Errorf("GoogleCanonical = %q, want %q", got.GoogleCanonical, tt.canonical) - } - if got.YandexLR != tt.lr { - t.Errorf("YandexLR = %q, want %q", got.YandexLR, tt.lr) - } - }) + if cc := CountryFromRegion("en-GB"); cc != "GB" { + t.Errorf("CountryFromRegion(en-GB) = %q, want GB", cc) + } + if loc := ParseLocale("de_AT"); loc.Language != "de" || loc.Country != "AT" { + t.Errorf("ParseLocale(de_AT) = %+v, want {de AT}", loc) } } diff --git a/google/url.go b/google/url.go index aeda40c..43a259a 100644 --- a/google/url.go +++ b/google/url.go @@ -1,7 +1,6 @@ package google import ( - "encoding/base64" "errors" "fmt" "net/url" @@ -9,6 +8,7 @@ import ( "strings" "github.com/karust/openserp/core" + regionpkg "github.com/karust/openserp/core/region" "github.com/sirupsen/logrus" ) @@ -226,10 +226,6 @@ var googleDefaultCountryByLanguage = map[string]string{ "ko": "kr", } -const googleUULEPrefix = "w+CAIQICI" - -var googleUULELengthAlphabet = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") - // BuildURL builds a Google web search URL from Query fields. // It returns an error when the resulting query text is empty or invalid. func BuildURL(q core.Query) (string, error) { @@ -431,41 +427,10 @@ func googleDomain(locale googleLocaleParams) string { return GoogleDomains[""] } -// googleUULE builds a Google UULE v1 value for a region hint. -// -// UULE only takes effect when the encoded string is an exact Google geotargets -// canonical name; a bare free-text city is silently ignored by Google. We -// therefore resolve the region first (core.ResolveRegion) and encode the -// resolved canonical name when one is found. Country-level hints intentionally -// produce no UULE — those are conveyed via gl= instead. +// googleUULE builds a Google UULE v1 value for a region hint. The resolution +// and encoding live in the dependency-free core/region subpackage. func googleUULE(region string) string { - target := core.ResolveRegion(region) - canonical := target.GoogleCanonical - if canonical == "" { - // No resolved canonical name. A bare country code (e.g. "DE") or a - // numeric Yandex lr ID must not become a UULE; both are handled - // elsewhere (gl=) or are not applicable to Google. - if target.Country != "" || target.YandexLR != "" { - return "" - } - // Best-effort: encode the raw free-text region. This may be ignored by - // Google if it isn't a canonical name, matching prior behavior. - canonical = strings.TrimSpace(region) - } - if canonical == "" { - return "" - } - return encodeGoogleUULE(canonical) -} - -// encodeGoogleUULE encodes a canonical location name into a UULE v1 value: -// a fixed prefix, a single length-tag character, then base64(name). -func encodeGoogleUULE(canonical string) string { - length := len([]rune(canonical)) - if length <= 0 || length >= len(googleUULELengthAlphabet) { - return "" - } - return googleUULEPrefix + string(googleUULELengthAlphabet[length]) + base64.StdEncoding.EncodeToString([]byte(canonical)) + return regionpkg.GoogleUULE(region) } // SourceImage contains parsed Google image metadata extracted from result links.