From 983330266aae42b4c15acd6e06be3d775ffc54de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20=C5=81apaj?= <36500945+mlapaj@users.noreply.github.com> Date: Wed, 24 Dec 2025 05:02:57 +0100 Subject: [PATCH] fix(purevpn/updater): parse country and city from hostname and merges with ip address information (#2991) --- internal/provider/purevpn/updater/compare.go | 59 +++++++++++++++++ .../provider/purevpn/updater/compare_test.go | 51 +++++++++++++++ internal/provider/purevpn/updater/parse.go | 65 +++++++++++++++++++ internal/provider/purevpn/updater/servers.go | 23 ++++++- 4 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 internal/provider/purevpn/updater/compare.go create mode 100644 internal/provider/purevpn/updater/compare_test.go create mode 100644 internal/provider/purevpn/updater/parse.go diff --git a/internal/provider/purevpn/updater/compare.go b/internal/provider/purevpn/updater/compare.go new file mode 100644 index 00000000..05914526 --- /dev/null +++ b/internal/provider/purevpn/updater/compare.go @@ -0,0 +1,59 @@ +package updater + +import ( + "strings" + "unicode" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +// comparePlaceNames returns true if strings are within 1 edit +// distance after normalization. +func comparePlaceNames(a, b string) bool { + normA := normalize(a) + normB := normalize(b) + return normA == normB || levenshteinDistance(normA, normB) <= 1 +} + +// normalize removes accents, trims space, and lowercases the string. +func normalize(s string) string { + transformer := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + result, _, err := transform.String(transformer, s) + if err != nil { + panic(err) + } + return strings.ToLower(strings.TrimSpace(result)) +} + +// levenshteinDistance calculates the edit distance +// between two strings a and b. +func levenshteinDistance(a, b string) int { + switch { + case len(a) == 0: + return len(b) + case len(b) == 0: + return len(a) + } + + column := make([]int, len(b)+1) + for i := 0; i <= len(b); i++ { + column[i] = i + } + + for i := 1; i <= len(a); i++ { + column[0] = i + lastValue := i - 1 + for j := 1; j <= len(b); j++ { + oldValue := column[j] + cost := 0 + if a[i-1] != b[j-1] { + cost = 1 + } + column[j] = min(column[j]+1, min(column[j-1]+1, lastValue+cost)) + lastValue = oldValue + } + } + return column[len(b)] +} diff --git a/internal/provider/purevpn/updater/compare_test.go b/internal/provider/purevpn/updater/compare_test.go new file mode 100644 index 00000000..8b889dec --- /dev/null +++ b/internal/provider/purevpn/updater/compare_test.go @@ -0,0 +1,51 @@ +package updater + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_comparePlaceNames(t *testing.T) { + t.Parallel() // Allow the top-level test to run in parallel + + testCases := map[string]struct { + a string + b string + want bool + }{ + "exact_match": { + a: "Paris", + b: "Paris", + want: true, + }, + "difference_in_casing_and_whitespace": { + a: " Montreal", + b: "montreal ", + want: true, + }, + "accent_normalization": { + a: "Montréal", + b: "Montreal", + want: true, + }, + "single_character_typo": { + a: "Lyon", + b: "Lyonn", + want: true, + }, + "too_many_differences": { + a: "London", + b: "Londres", + want: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + result := comparePlaceNames(testCase.a, testCase.b) + assert.Equal(t, testCase.want, result) + }) + } +} diff --git a/internal/provider/purevpn/updater/parse.go b/internal/provider/purevpn/updater/parse.go new file mode 100644 index 00000000..5e54ed75 --- /dev/null +++ b/internal/provider/purevpn/updater/parse.go @@ -0,0 +1,65 @@ +package updater + +import ( + "fmt" + "strings" + + "github.com/qdm12/gluetun/internal/constants" +) + +var countryCodeToName = constants.CountryCodes() //nolint:gochecknoglobals + +//nolint:gochecknoglobals +var countryCityCodeToCityName = map[string]string{ + "aume": "Melbourne", + "aupe": "Perth", + "ausd": "Sydney", + "ukl": "London", + "ukm": "Manchester", + "usca": "Los Angeles", + "usfl": "Miami", + "usga": "Atlanta", + "usil": "Chicago", + "usnj": "Newark", + "usny": "New York", + "uspe": "Perth", + "usphx": "Phoenix", + "ussa": "Seattle", + "ussf": "San Francisco", + "ustx": "Houston", + "usut": "Salt Lake City", + "usva": "Ashburn", + "uswdc": "Washington DC", +} + +func parseHostname(hostname string) (country, city string, warnings []string) { + const minHostnameLength = 2 + 3 + 2 // 2 country code + 3 city code + "2-" + if len(hostname) < minHostnameLength { + warnings = append(warnings, + fmt.Sprintf("hostname %q is too short to parse country and city codes", hostname)) + } + countryCode := strings.ToLower(hostname[0:2]) + country, ok := countryCodeToName[countryCode] + if !ok { + warnings = append(warnings, fmt.Sprintf("unknown country code %q in hostname %q", + countryCode, hostname)) + } + + twoMinusIndex := strings.Index(hostname, "2-") + switch twoMinusIndex { + case -1: + warnings = append(warnings, + fmt.Sprintf("hostname %q does not contain '2-'", hostname)) + return country, city, warnings + case 2: //nolint:mnd + // no city code + return country, "", warnings + } + countryCityCode := strings.ToLower(hostname[:twoMinusIndex]) + city, ok = countryCityCodeToCityName[countryCityCode] + if !ok { + warnings = append(warnings, fmt.Sprintf("unknown country-city code %q in hostname %q", + countryCityCode, hostname)) + } + return country, city, warnings +} diff --git a/internal/provider/purevpn/updater/servers.go b/internal/provider/purevpn/updater/servers.go index 6748968e..3d40ec83 100644 --- a/internal/provider/purevpn/updater/servers.go +++ b/internal/provider/purevpn/updater/servers.go @@ -90,10 +90,27 @@ func (u *Updater) FetchServers(ctx context.Context, minServers int) ( if err != nil { return nil, err } + for i := range servers { - servers[i].Country = ipsInfo[i].Country - servers[i].Region = ipsInfo[i].Region - servers[i].City = ipsInfo[i].City + parsedCountry, parsedCity, warnings := parseHostname(servers[i].Hostname) + for _, warning := range warnings { + u.warner.Warn(warning) + } + servers[i].Country = parsedCountry + if servers[i].Country == "" { + servers[i].Country = ipsInfo[i].Country + } + servers[i].City = parsedCity + if servers[i].City == "" { + servers[i].City = ipsInfo[i].City + } + + if (parsedCountry == "" || + comparePlaceNames(parsedCountry, ipsInfo[i].Country)) && + (parsedCity == "" || + comparePlaceNames(parsedCity, ipsInfo[i].City)) { + servers[i].Region = ipsInfo[i].Region + } } sort.Sort(models.SortableServers(servers))