orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "fmt"
5 "log/slog"
6 "strings"
7 "sync"
8
9 "github.com/ua-parser/uap-go/uaparser"
10)
11
12// UAParser turns a User-Agent string into a platform, browser, device class and
13// a verdict on whether the sender is a robot.
14type UAParser struct {
15 once sync.Once
16 parser *uaparser.Parser
17 err error
18}
19
20// ParsedUA is the enrichment written onto every event.
21type ParsedUA struct {
22 Platform string
23 Browser string
24 Device string // Mobile, Tablet or Desktop
25 IsBot bool
26 BotName string
27}
28
29// NewUAParser builds a parser lazily; compiling the uap-core regex set is the
30// most expensive thing this process would do at startup.
31func NewUAParser() *UAParser { return &UAParser{} }
32
33func (u *UAParser) get() *uaparser.Parser {
34 u.once.Do(func() {
35 u.parser, u.err = uaparser.New()
36 if u.err != nil {
37 slog.Error(fmt.Sprintf("ua parser build failed, falling back to heuristics: %v", u.err))
38 }
39 })
40 return u.parser
41}
42
43// uap-core reports a crawler as one of these device families.
44var spiderFamilies = map[string]bool{
45 "Spider": true,
46 "Spider Desktop": true,
47 "Spider Smartphone": true,
48 "Spider Tablet": true,
49}
50
51func (u *UAParser) Parse(ua string) ParsedUA {
52 parser := u.get()
53 if parser == nil {
54 isBot := looksLikeBot(ua)
55 out := ParsedUA{IsBot: isBot}
56 if isBot {
57 out.BotName = "Unknown bot"
58 } else {
59 out.Device = classifyDevice(ua, "")
60 }
61 return out
62 }
63
64 client := parser.Parse(ua)
65
66 // uap-core writes the literal string "Other" when nothing matched.
67 platform := notOther(client.Os.Family)
68 browser := notOther(client.UserAgent.Family)
69 deviceFamily := client.Device.Family
70
71 // uap-core knows the crawlers that declare themselves; the needle list
72 // catches preview fetchers and uptime probes it reads as browsers.
73 isBot := spiderFamilies[deviceFamily] || looksLikeBot(ua)
74
75 out := ParsedUA{Platform: platform, Browser: browser, IsBot: isBot}
76 if isBot {
77 out.BotName = browser
78 } else {
79 out.Device = classifyDevice(ua, deviceFamily)
80 }
81 return out
82}
83
84func notOther(family string) string {
85 if family == "Other" {
86 return ""
87 }
88 return family
89}
90
91// looksLikeBot is the substring pass over the raw User-Agent. It is broad,
92// since a false positive only lands in bot_events while a missed bot inflates
93// every human metric.
94var botNeedles = []string{
95 "bot", "crawl", "spider", "slurp", "facebookexternalhit", "ahrefs", "semrush",
96 "petalbot", "yandex", "bingpreview", "duckduckgo", "discordbot", "whatsapp",
97 "telegrambot", "applebot", "linkedinbot", "embedly", "headlesschrome",
98 "phantomjs", "lighthouse", "pingdom", "uptimerobot", "monitor",
99}
100
101func looksLikeBot(ua string) bool {
102 lower := strings.ToLower(ua)
103 for _, n := range botNeedles {
104 if strings.Contains(lower, n) {
105 return true
106 }
107 }
108 return false
109}
110
111// classifyDevice collapses uap-core's device families into the three buckets the
112// dashboard charts. Tablet is tested first: an iPad's User-Agent says neither
113// "mobile" nor "iphone", and an Android tablet's says "mobile".
114func classifyDevice(ua, family string) string {
115 lower := strings.ToLower(ua)
116
117 if family == "iPad" || family == "Tablet" ||
118 strings.Contains(lower, "tablet") || strings.Contains(lower, "ipad") {
119 return "Tablet"
120 }
121 if family == "iPhone" || family == "iPod" || family == "Generic Smartphone" ||
122 strings.Contains(lower, "mobile") || strings.Contains(lower, "iphone") ||
123 strings.Contains(lower, "android") {
124 return "Mobile"
125 }
126 return "Desktop"
127}