RenameCollectionNodes now reads geo info from cached egress-probe fields (country, countryCode, city, flag) on each node instead of doing per-node DNS lookups via DetectGeoWithServer. When no cached egress data is available (probe not yet run), it falls back to name-based regex only (DetectGeo, no DNS) so the UI is never blocked. The background egress probe (triggered on source create/update) populates the cache asynchronously. Also: runEgressProbe now stores countryCode and flag emoji from ipwho.is response. egressCacheKey and addCachedEgressInfo updated to include the new fields. Reverted previous name-first hack on DetectGeoWithServer and DNS cache on geoip.LookupHost — no longer needed since rename doesn't call them. Collection preview with rename: 30s -> 52ms (with cache miss, name-based fallback).
255 lines
7.0 KiB
Go
255 lines
7.0 KiB
Go
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 looking up the proxy's
|
||
// server address via GeoIP first, falling back to name-based detection.
|
||
func DetectGeoWithServer(name, server string) GeoInfo {
|
||
// Try GeoIP lookup first
|
||
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,
|
||
}
|
||
}
|
||
}
|
||
// Fallback to name-based detection
|
||
return detectGeoFromName(name)
|
||
}
|
||
|
||
// 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+1F1E6–U+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
|
||
}
|