Files
sub-store/internal/util/flag.go
T
rogee de9ac92ede feat: GeoIP-based geo detection + configurable rename template
- Add MaxMind GeoLite2-City mmdb auto-download and IP-based geo lookup
  (covers all 249 countries globally, replacing limited 8-country regex)
- Add RenameOptions with 5 toggleable fields (flag/alias/country/city/index)
  stored as rename_options_json on collections
- Collection rename pipeline uses DetectGeoWithServer(name, server) which
  tries GeoIP lookup first, falls back to name-based regex matching
- Chinese country/city names + emoji flag prefix
- Country-city dedup: skip city when it equals country (e.g. 香港/香港)
- URI fragment encoding: url.QueryEscape -> url.PathEscape (%20 not +)
- Egress cache key excludes name field so renamed nodes still match cache
- Frontend: 5 toggle buttons in collection edit form, default all on
- Migrations: 0004 (source alias + collection rename_enabled),
  0005 (collection rename_options_json)
2026-07-28 14:44:54 +08:00

255 lines
7.0 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 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+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
}