repos
/ orchard main

orchard

mirror

Every 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

3.4 KB · 128 lines · Go Raw History
  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}