Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-22 14:47:17 +07:00
co-authored by Claude Opus 5.5
commit 3639d2184a
574 changed files with 61690 additions and 0 deletions
+340
View File
@@ -0,0 +1,340 @@
package useragent
import (
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
const DefaultVer = "1.0.0"
// Info 与前端 X-User-Agent 字段对齐,供 SSR 组装上游请求头。
type Info struct {
RawUA string
DevType string
SysType string
Ver string
BuildID string
DeviceBrand string
DeviceModel string
SystemName string
SystemVersion string
SID string
}
// BuildOptions SSR / 代理侧可选覆盖项。
type BuildOptions struct {
Ver string
SID string
// RawUA 为空时从 Request.UserAgent 读取
RawUA string
}
var (
reIOSVer = regexp.MustCompile(`OS (\d+[_\d]*) like Mac OS X`)
reAndroidVer = regexp.MustCompile(`Android ([\d.]+)`)
reAndroidModel = regexp.MustCompile(`Android\s+[\d.]+;\s*([^;)]+?)(?:\s+Build\/|\))`)
reWinNT = regexp.MustCompile(`Windows NT ([\d.]+)`)
reMacVer = regexp.MustCompile(`Mac OS X ([\d_]+)`)
reEdge = regexp.MustCompile(`(?i)Edg/([\d.]+)`)
reOpera = regexp.MustCompile(`(?i)(?:OPR|Opera)/([\d.]+)`)
reChrome = regexp.MustCompile(`(?i)Chrome/([\d.]+)`)
reSafari = regexp.MustCompile(`(?i)Version/([\d.]+).*Safari`)
reFirefox = regexp.MustCompile(`(?i)Firefox/([\d.]+)`)
reIE = regexp.MustCompile(`(?i)(?:MSIE\s|Trident.*rv:)([\d.]+)`)
)
// ParseDeviceFromUA 从浏览器原生 User-Agent 解析设备信息(服务端无 navigator)。
func ParseDeviceFromUA(ua string) (brand, model, deviceType, systemName, systemVersion string) {
ua = strings.TrimSpace(ua)
switch {
case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad") || strings.Contains(ua, "iPod"):
systemName = "iOS"
brand = "Apple"
if m := reIOSVer.FindStringSubmatch(ua); len(m) > 1 {
systemVersion = strings.ReplaceAll(m[1], "_", ".")
}
switch {
case strings.Contains(ua, "iPad"):
model, deviceType = "iPad", "tablet"
case strings.Contains(ua, "iPod"):
model, deviceType = "iPod", "mobile"
default:
model, deviceType = "iPhone", "mobile"
}
case strings.Contains(ua, "Android"):
systemName = "Android"
if m := reAndroidVer.FindStringSubmatch(ua); len(m) > 1 {
systemVersion = m[1]
}
if m := reAndroidModel.FindStringSubmatch(ua); len(m) > 1 {
model = strings.TrimSpace(m[1])
}
brand = guessAndroidBrand(model, ua)
if strings.Contains(ua, "Mobile") {
deviceType = "mobile"
} else {
deviceType = "tablet"
}
case strings.Contains(ua, "Windows"):
systemName = "Windows"
deviceType = "desktop"
if m := reWinNT.FindStringSubmatch(ua); len(m) > 1 {
systemVersion = m[1]
}
case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"):
systemName = "macOS"
brand = "Apple"
model = "Mac"
deviceType = "desktop"
if m := reMacVer.FindStringSubmatch(ua); len(m) > 1 {
systemVersion = strings.ReplaceAll(m[1], "_", ".")
}
case strings.Contains(ua, "Linux"):
systemName = "Linux"
deviceType = "desktop"
default:
systemName = "Other"
deviceType = "desktop"
}
return
}
func guessAndroidBrand(model, ua string) string {
s := model + " " + ua
rules := []struct {
re *regexp.Regexp
brand string
}{
{regexp.MustCompile(`(?i)Build/HONOR|\bHONOR\b`), "Honor"},
{regexp.MustCompile(`(?i)Build/HUAWEI|HUAWEI|HW-`), "Huawei"},
{regexp.MustCompile(`(?i)SM-|samsung|Galaxy`), "Samsung"},
{regexp.MustCompile(`(?i)Pixel`), "Google"},
{regexp.MustCompile(`(?i)Redmi|POCO|XiaoMi|\bMi\b`), "Xiaomi"},
{regexp.MustCompile(`(?i)OPPO|\bCPH\d{3,4}`), "OPPO"},
{regexp.MustCompile(`(?i)vivo|\bV\d{4}`), "vivo"},
{regexp.MustCompile(`(?i)OnePlus|ONEPLUS`), "OnePlus"},
{regexp.MustCompile(`(?i)realme|\bRMX\d{3,4}`), "realme"},
}
for _, r := range rules {
if r.re.MatchString(s) {
return r.brand
}
}
return ""
}
func detectSysType(ua string) string {
uaLower := strings.ToLower(ua)
switch {
case strings.Contains(uaLower, "android"):
return "h5_android"
case strings.Contains(uaLower, "iphone"), strings.Contains(uaLower, "ipad"), strings.Contains(uaLower, "ipod"):
return "h5_ios"
default:
return "h5_pc"
}
}
func parseBrowser(ua string) (name, version string) {
switch {
case reEdge.MatchString(ua):
m := reEdge.FindStringSubmatch(ua)
return "Edge", m[1]
case reOpera.MatchString(ua):
m := reOpera.FindStringSubmatch(ua)
return "Opera", m[1]
case reChrome.MatchString(ua) && !reEdge.MatchString(ua):
m := reChrome.FindStringSubmatch(ua)
return "Chrome", m[1]
case reSafari.MatchString(ua):
m := reSafari.FindStringSubmatch(ua)
return "Safari", m[1]
case reFirefox.MatchString(ua):
m := reFirefox.FindStringSubmatch(ua)
return "Firefox", m[1]
case reIE.MatchString(ua):
m := reIE.FindStringSubmatch(ua)
return "IE", m[1]
default:
return "", ""
}
}
func buildDevType(brand, model, deviceType string) string {
parts := make([]string, 0, 3)
for _, v := range []string{brand, model, deviceType} {
v = strings.TrimSpace(v)
if v != "" {
parts = append(parts, v)
}
}
if len(parts) < 3 {
return "Apple iPhone mobile"
}
return strings.Join(parts, " ")
}
// BuildInfo 根据原始 UA 组装结构化信息。
func BuildInfo(opts BuildOptions) Info {
ua := strings.TrimSpace(opts.RawUA)
ver := strings.TrimSpace(opts.Ver)
if ver == "" {
ver = DefaultVer
}
brand, model, deviceType, systemName, systemVersion := ParseDeviceFromUA(ua)
browserName, browserVer := parseBrowser(ua)
buildID := strings.TrimSpace(strings.TrimSpace(browserName + " " + browserVer))
return Info{
RawUA: ua,
DevType: buildDevType(brand, model, deviceType),
SysType: detectSysType(ua),
Ver: ver,
BuildID: buildID,
DeviceBrand: brand,
DeviceModel: model,
SystemName: systemName,
SystemVersion: systemVersion,
SID: strings.TrimSpace(opts.SID),
}
}
// String 输出 X-User-Agent Header 值。
func (info Info) String() string {
return fmt.Sprintf(
"DevType=%s;SysType=%s;Ver=%s;BuildID=%s;device_brand=%s;device_model=%s;system_name=%s;system_version=%s;sid=%s",
info.DevType,
info.SysType,
info.Ver,
info.BuildID,
info.DeviceBrand,
info.DeviceModel,
info.SystemName,
info.SystemVersion,
info.SID,
)
}
// BuildXUserAgent 直接返回 Header 字符串。
func BuildXUserAgent(opts BuildOptions) string {
return BuildInfo(opts).String()
}
// SIDFromRequest 从 cookie 读取 sid(与前端约定:sid / _s_token_)。
func SIDFromRequest(r *http.Request) string {
if r == nil {
return ""
}
if c, err := r.Cookie("sid"); err == nil && c != nil {
if v := strings.TrimSpace(c.Value); v != "" {
return v
}
}
if c, err := r.Cookie("_s_token_"); err == nil && c != nil {
v := strings.TrimSpace(c.Value)
// 可能是 JSON:{"sid":"..."} 或纯字符串
if strings.HasPrefix(v, "{") {
const key = `"sid"`
if i := strings.Index(v, key); i >= 0 {
rest := v[i+len(key):]
if j := strings.Index(rest, `"`); j >= 0 {
rest = rest[j+1:]
if k := strings.Index(rest, `"`); k >= 0 {
return rest[:k]
}
}
}
}
return v
}
return ""
}
// BuildXUserAgentFromRequest 供 SSR 反向代理上游 API 使用。
// 优先透传客户端已带的 X-User-Agent;否则用浏览器 UA 组装。
func BuildXUserAgentFromRequest(c *gin.Context, ver string) string {
if c == nil || c.Request == nil {
return BuildXUserAgent(BuildOptions{Ver: ver})
}
if xua := strings.TrimSpace(c.GetHeader("X-User-Agent")); xua != "" {
return xua
}
return BuildXUserAgent(BuildOptions{
Ver: ver,
SID: SIDFromRequest(c.Request),
RawUA: c.Request.UserAgent(),
})
}
// TokenFromRequest 从 cookie 读取登录 token(与前端约定:token / _token_)。
func TokenFromRequest(r *http.Request) string {
if r == nil {
return ""
}
for _, name := range []string{"token", "_token_"} {
if c, err := r.Cookie(name); err == nil && c != nil {
if v := cookieUnescape(c.Value); v != "" {
return v
}
}
}
return ""
}
func cookieUnescape(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if decoded, err := url.QueryUnescape(raw); err == nil {
return strings.TrimSpace(decoded)
}
return raw
}
// ClientIPFromRequest 优先透传已有 X-Real-IP / X-Forwarded-For,否则用远端地址。
func ClientIPFromRequest(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
if ip := strings.TrimSpace(c.GetHeader("X-Real-IP")); ip != "" {
return ip
}
if xff := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); xff != "" {
if i := strings.Index(xff, ","); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return xff
}
return strings.TrimSpace(c.ClientIP())
}
// BuildUpstreamHeader 组装上游 API 请求头:X-Real-IP、User-Agent、X-User-Agent。
// 登录 token / sid 已在 cookie 中时由调用方按需写入 Authorization 等字段。
func BuildUpstreamHeader(c *gin.Context, ver string) map[string]string {
header := make(map[string]string, 8)
ApplyToHeader(header, c, ver)
return header
}
// ApplyToHeader 将上游必需头写入 header map:
// X-Real-IP、User-Agent、X-User-Agent。
func ApplyToHeader(header map[string]string, c *gin.Context, ver string) {
if header == nil {
return
}
if c != nil && c.Request != nil {
if ip := ClientIPFromRequest(c); ip != "" {
header["X-Real-IP"] = ip
}
if ua := strings.TrimSpace(c.Request.UserAgent()); ua != "" {
header["User-Agent"] = ua
}
}
header["X-User-Agent"] = BuildXUserAgentFromRequest(c, ver)
}