Files
sub-store/internal/util/flag.go
T
rogee 8e449c8531 perf: name-first geo detection to avoid serial DNS in collection rename
DetectGeoWithServer was doing GeoIP-first (DNS lookup per node) then falling back to name regex. For a 148-node collection with rename enabled, this meant 148 serial DNS queries in the rename pass alone (28s on slow DNS).

Reverse the priority: try name-based regex first (instant, no I/O), only fall back to GeoIP+DNS when the node name has no recognizable country keywords. Most proxy names already contain country/city info.

Also add in-memory DNS cache in geoip.LookupHost so repeated lookups for the same hostname don't re-resolve.

Collection preview with rename: 30s -> 3s.
2026-07-30 18:40:18 +08:00

259 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package util
import (
"net"
"regexp"
"strings"
"github.com/peterqiu0516/sub-store/internal/geoip"
)
// Flag detection rules: [regex, emoji, countryName, cityExtractor].
// Per review-resolution #16: Go RE2 supports \p{Regional_Indicator} and \uFE0F.
type geoRule struct {
re *regexp.Regexp
emoji string
countryName string
countryNameCN string
cityPatterns []cityPattern
}
type cityPattern struct {
re *regexp.Regexp
name string
nameCN string
}
var geoRules []geoRule
func init() {
rules := []struct {
pattern string
emoji string
countryName string
countryNameCN string
cities []struct{ pattern, name, nameCN string }
}{
{
pattern: `香港|港|hong\s*kong|\bhk\b`,
emoji: "🇭🇰",
countryName: "Hong Kong",
countryNameCN: "香港",
cities: []struct{ pattern, name, nameCN string }{
{`香港|Hong\s*Kong|HK`, "Hong Kong", "香港"},
},
},
{
pattern: `台湾|台灣|taiwan|\btw\b`,
emoji: "🇹🇼",
countryName: "Taiwan",
countryNameCN: "台湾",
cities: []struct{ pattern, name, nameCN string }{
{`台北|臺北|Taipei`, "Taipei", "台北"},
{`高雄|Gaoxiong|Kaohsiung`, "Kaohsiung", "高雄"},
},
},
{
pattern: `新加坡|狮城|獅城|singapore|\bsg\b`,
emoji: "🇸🇬",
countryName: "Singapore",
countryNameCN: "新加坡",
cities: []struct{ pattern, name, nameCN string }{
{`新加坡|Singapore|SG`, "Singapore", "新加坡"},
},
},
{
pattern: `日本|东京|東京|大阪|japan|tokyo|osaka|\bjp\b`,
emoji: "🇯🇵",
countryName: "Japan",
countryNameCN: "日本",
cities: []struct{ pattern, name, nameCN string }{
{`东京|東京|Tokyo`, "Tokyo", "东京"},
{`大阪|Osaka`, "Osaka", "大阪"},
},
},
{
pattern: `美国|美國|洛杉矶|洛杉磯|纽约|紐約|united\s*states|los\s*angeles|new\s*york|\bus\b|\busa\b`,
emoji: "🇺🇸",
countryName: "United States",
countryNameCN: "美国",
cities: []struct{ pattern, name, nameCN string }{
{`洛杉矶|洛杉磯|Los\s*Angeles|LA`, "Los Angeles", "洛杉矶"},
{`纽约|紐約|New\s*York|NYC`, "New York", "纽约"},
{`硅谷|Silicon\s*Valley|San\s*Jose|SJC`, "San Jose", "硅谷"},
{`西雅图|Seattle`, "Seattle", "西雅图"},
{`芝加哥|Chicago`, "Chicago", "芝加哥"},
{`达拉斯|Dallas`, "Dallas", "达拉斯"},
},
},
{
pattern: `英国|英國|伦敦|倫敦|united\s*kingdom|london|\buk\b`,
emoji: "🇬🇧",
countryName: "United Kingdom",
countryNameCN: "英国",
cities: []struct{ pattern, name, nameCN string }{
{`伦敦|倫敦|London`, "London", "伦敦"},
},
},
{
pattern: `德国|德國|法兰克福|法蘭克福|germany|frankfurt|\bde\b`,
emoji: "🇩🇪",
countryName: "Germany",
countryNameCN: "德国",
cities: []struct{ pattern, name, nameCN string }{
{`法兰克福|法蘭克福|Frankfurt`, "Frankfurt", "法兰克福"},
},
},
{
pattern: `韩国|韓國|首尔|首爾|korea|seoul|\bkr\b`,
emoji: "🇰🇷",
countryName: "South Korea",
countryNameCN: "韩国",
cities: []struct{ pattern, name, nameCN string }{
{`首尔|首爾|Seoul`, "Seoul", "首尔"},
},
},
}
for _, r := range rules {
re, err := regexp.Compile("(?i)" + r.pattern)
if err != nil {
continue
}
var cps []cityPattern
for _, c := range r.cities {
cre, err := regexp.Compile("(?i)" + c.pattern)
if err != nil {
continue
}
cps = append(cps, cityPattern{re: cre, name: c.name, nameCN: c.nameCN})
}
geoRules = append(geoRules, geoRule{
re: re,
emoji: r.emoji,
countryName: r.countryName,
countryNameCN: r.countryNameCN,
cityPatterns: cps,
})
}
}
// GeoInfo holds detected geographic information from a proxy name.
type GeoInfo struct {
Flag string // emoji flag
CountryName string // English country name
CountryCN string // Chinese country name
City string // English city name (may be empty)
CityCN string // Chinese city name (may be empty)
}
// DetectFlag returns the flag emoji for a name, or 🏳️ if no match.
func DetectFlag(name string) string {
text := strings.ToLower(name)
for _, rule := range geoRules {
if rule.re.MatchString(text) {
return rule.emoji
}
}
return "🏳️"
}
// DetectGeo returns full geographic info (flag + country + city) from a name.
// It tries GeoIP lookup on the server address first, falling back to
// name-based regex matching.
func DetectGeo(name string) GeoInfo {
return detectGeoFromName(name)
}
// DetectGeoWithServer returns geographic info by first trying name-based
// regex matching (fast, no I/O), falling back to GeoIP lookup on the server
// address only when the name doesn't contain recognizable geo keywords.
func DetectGeoWithServer(name, server string) GeoInfo {
// Try name-based detection first (fast, no I/O)
if geo := detectGeoFromName(name); geo.CountryName != "" {
return geo
}
// Fallback to GeoIP lookup (slow, involves DNS)
if server != "" {
host := server
// Strip port if present
if h, _, err := net.SplitHostPort(server); err == nil {
host = h
}
if result := geoip.LookupHost(host); result != nil {
return GeoInfo{
Flag: geoip.CountryEmoji(result.CountryCode),
CountryName: result.CountryName,
CountryCN: result.CountryNameCN,
City: result.CityName,
CityCN: result.CityNameCN,
}
}
}
return GeoInfo{}
}
// detectGeoFromName does name-based regex matching for geo detection.
func detectGeoFromName(name string) GeoInfo {
text := strings.ToLower(name)
for _, rule := range geoRules {
if rule.re.MatchString(text) {
city := ""
cityCN := ""
for _, cp := range rule.cityPatterns {
if cp.re.MatchString(text) {
city = cp.name
cityCN = cp.nameCN
break
}
}
return GeoInfo{
Flag: rule.emoji,
CountryName: rule.countryName,
CountryCN: rule.countryNameCN,
City: city,
CityCN: cityCN,
}
}
}
return GeoInfo{}
}
// removeFlagRe matches leading flag emoji sequences and whitespace.
// Regional Indicator letters are U+1F1E6U+1F1FF; ZWJ is U+200D; VS16 is U+FE0F.
var removeFlagRe = regexp.MustCompile(`^[\x{1F1E6}-\x{1F1FF}\x{FE0F}\x{200D}\s]+`)
// RemoveFlag strips leading flag emoji and whitespace from a name.
// Per review-resolution #16: handles ZWJ (\u200D) and variation selector (\uFE0F).
func RemoveFlag(name string) string {
cleaned := removeFlagRe.ReplaceAllString(name, "")
// Also strip leading 🏳️
cleaned = strings.TrimLeft(cleaned, "🏳️ ")
return strings.TrimSpace(cleaned)
}
// NormalizeTaiwanFlag maps the Taiwan flag to different flags based on `tw` mode.
// Per review-resolution #19: three modes — ws/tw/default.
func NormalizeTaiwanFlag(flag, mode string) string {
if flag != "🇹🇼" {
return flag
}
switch mode {
case "ws":
return "🇼🇸"
case "tw":
return "🇹🇼"
default:
return "🇨🇳"
}
}
// IsASCII checks if a string contains only ASCII characters.
func IsASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] > 127 {
return false
}
}
return true
}