uarite 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ .*
10
+ !.gitignore
11
+ *.lock
uarite-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: uarite
3
+ Version: 0.1.0
4
+ Summary: Compact, human-readable User-Agent formatting and bot detection — no regex database, stdlib only
5
+ Project-URL: Repository, https://git.zi.fi/LeoVasanko/uarite
6
+ Project-URL: Issues, https://github.com/LeoVasanko/uarite
7
+ Author: Leo Vasanko
8
+ Keywords: ua-parser,user-agent
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Internet :: WWW/HTTP
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # uarite
18
+
19
+ User-Agent parsing in Python has a long lineage. ua-parser is the official Python implementation of the ua-parser project, built around uap-core: the regex database extracted from BrowserScope's original parser and shared by implementations in many languages. user-agents wraps ua-parser with higher-level device and capability detection; its last release was in 2020. user-agent-parser is a separate implementation first released in 2022 and substantially updated in 2026, taking its own approach rather than building on uap-core. None of the three has further dependencies, but the regex databases weigh something: ua-parser installs at about 499 KB (531 KB with user-agents on top), user-agent-parser at 166 KB.
20
+
21
+ ```sh
22
+ uv add uarite
23
+ ```
24
+
25
+ This module is another take on the same problem: a small, dependency-free, compact pure-Python parser — 29 KB installed. It returns structured classifications, but also the thing most applications eventually need: a short human-readable description.
26
+
27
+ It is particularly aimed at server-side analytics, where correctly recognizing crawlers and modern reduced User-Agents matters. It detects disguised crawlers, distinguishes AI/search/preview traffic, handles HarmonyOS and bots without calling them Android, resolves common device model codes and falls back to reasonable output even when all else fails.
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from uarite import uaparse
33
+
34
+ r = uaparse("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
35
+ "(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36")
36
+
37
+ r.pretty # "Chrome/152 Windows"
38
+ r.kind # "browser"
39
+ r.bot # ""
40
+ r.url # ""
41
+
42
+ r = uaparse("Mozilla/5.0 (Linux; Android 13; Pixel 7) ... Chrome/134.0.6885.65 "
43
+ "Mobile Safari/537.36; compatible; facebookexternalhit/1.1; "
44
+ "+http://www.facebook.com/externalhit_uatext.php")
45
+
46
+ r.pretty # "Facebook"
47
+ r.kind # "preview"
48
+ r.bot # "Facebook"
49
+ r.url # "http://www.facebook.com/externalhit_uatext.php"
50
+ ```
51
+
52
+ ## Output
53
+
54
+ `uaparse(ua)` returns a frozen `UA` dataclass:
55
+
56
+ | Field | Content |
57
+ | -------- | ------------------------------------------------------------------------------------------------ |
58
+ | `pretty` | Compact display string (below); `""` for empty/missing UAs, the raw UA when unrecognized |
59
+ | `bot` | Crawler/previewer display name, or `""` |
60
+ | `kind` | `"browser"`, `"ai"`, `"search"`, `"preview"`, `"spider"`, or `""` (scripts/HTTP libraries) |
61
+ | `url` | The crawler's info URL (`+https://…` pointer), or `""`; not part of `pretty` — link it in the UI |
62
+
63
+ `kind` is `"browser"` for Mozilla-format UAs with no bot token, `"ai"` for training-data and AI-assistant fetchers (GPTBot, ClaudeBot, Google-Extended, ...), `"search"` for search-engine indexing (Googlebot, Bingbot, ...), `"preview"` for social link-preview fetchers (Facebook, WhatsApp, Slack, ...), `"spider"` for generic or unknown crawlers, and `""` for scripts and HTTP libraries.
64
+
65
+ `pretty` is intended to be shown directly:
66
+
67
+ - Desktop: `Chrome/152 Windows`, `Safari/18 macOS`
68
+ - iPhone/iPad: `iPhone iOS 17` — the device and iOS version, not Safari (the only browser iOS has)
69
+ - Android: `Chrome/118 Pixel 6`, or `Chrome/152 Android` when the device is unknown
70
+ - Crawlers: `GPTBot (AI)`, `Googlebot (search)`, `Facebook` — the kind suffix appears only where a provider runs crawlers of more than one kind; single-kind providers stay plain
71
+ - Scripts: `python-requests/2.32.5`, `pip/24.3.1 Linux`
72
+
73
+ Chrome's reduced Android UA reports the frozen values `Android 10; K`; neither is real device information, so uarite deliberately reports simply `Android`. HarmonyOS compatibility strings are similarly recognized before their misleading Android tokens.
74
+
75
+ ## Performance
76
+
77
+ All compared parsers cache repeated User-Agents, making cache hits effectively free. The useful difference is therefore the first parse of a new string.
78
+
79
+ In our benchmarks, uncached uarite parses take roughly **3–7 µs**. user-agent-parser is in the same general range at **~7 µs**, while the pure-Python ua-parser/user-agents path takes roughly **130–250 µs**.
80
+
81
+ The cache strategies differ in ways that matter under adversarial traffic. uarite caches only browser/client results (1024-entry LRU): crawlers tend to be unique and would otherwise evict the repeating UAs where caching is useful. user-agent-parser's 512-entry LRU lets a bot storm evict browsers, and user-agents' 200-entry dict clears entirely when full.
82
+
83
+ ## Accuracy
84
+
85
+ The main difference is not how many fields can be returned, but what the parser believes the User-Agent actually says.
86
+
87
+ For example, reduced Chrome does not really tell us that the device is named `K` or that it runs Android 10; an Android compatibility token does not make HarmonyOS Android; and a Facebook or Google crawler containing a plausible Chrome UA is still a crawler, not a Chrome visitor.
88
+
89
+ The table below compares representative results. uarite shows `r.pretty`; the ua-parser display strings are assembled from its structured output for comparison. user-agents is omitted: it shares the ua-parser backend and returns virtually identical data in a slightly different structure.
90
+
91
+ | Case | uarite¹ | ua-parser² |
92
+ | ---------------------------- | ------------------------- | --------------------------------------------- |
93
+ | Chrome, Windows | Chrome/152 Windows | Chrome/152 Windows |
94
+ | Safari, macOS | Safari/18 macOS | Safari/18 Mac OS X Mac |
95
+ | Opera, Linux | Opera/106 Linux | Opera/106 Linux |
96
+ | Chrome, Android (no model) | Chrome/152 Android | Chrome Mobile/152 Android K❌ |
97
+ | Chrome, Android (Pixel) | Chrome/118 Pixel 6 | Chrome Mobile/118 Android Pixel 6 |
98
+ | Edge, Android (model code) | Edge/110 Galaxy S7 | Edge Mobile/110 Android Samsung SM-G930P |
99
+ | Firefox, Android | Firefox/154 Android 15 | Firefox Mobile/154 Android Generic Smartphone |
100
+ | Safari, iPhone | iPhone iOS 17 | Mobile Safari/17 iOS iPhone |
101
+ | Huawei HarmonyOS phone | HuaweiBrowser/6 HarmonyOS | Huawei Browser/6 Android❌ Huawei Browser |
102
+ | GPTBot | GPTBot (AI) | GPTBot/1 Spider |
103
+ | Googlebot (disguised) | Googlebot (search) | Googlebot/2 Android❌ Spider |
104
+ | Facebook preview (disguised) | Facebook | FacebookBot/1 Android Pixel 7 ❌ |
105
+ | Meta crawler (disguised) | Meta | Chrome/145 Windows ❌ |
106
+ | WhatsApp preview | WhatsApp | WhatsApp/10 Spider |
107
+ | Bytespider | Bytespider | Bytespider/ Android❌ Generic Smartphone |
108
+ | BingPreview | BingPreview (preview) | BingPreview/1 Windows❌ Spider |
109
+ | AhrefsBot | AhrefsBot | AhrefsBot/7 Spider |
110
+ | python-requests | python-requests/2.32.5 | Python Requests/2 |
111
+
112
+ ❌ marks an incorrect browser, OS, or device interpretation.
113
+ ¹ `r.pretty` shown as is
114
+ ² `{user_agent.family}/{user_agent.major} {os.family} {device.family}`
115
+
116
+ Measured on 100 current browser UAs, family / version / OS accuracy is 80% / 91% / 99% for both ua-parser and user-agents, and 90% / 90% / 100% for uarite — its nominal "misses" are the iPhone rows, where it reports `iPhone iOS 17` rather than Mobile Safari, a deliberate choice since Safari is the only browser iOS has. user-agent-parser is absent from the table: it detected under a third of the crawlers in the test below and crashed on five inputs, so a side-by-side formatting comparison adds little.
117
+
118
+ Crawler detection was also tested against 2163 real-world crawler UAs from [monperrus/crawler-user-agents](https://github.com/monperrus/crawler-user-agents):
119
+
120
+ | Parser | Detected |
121
+ | ----------------- | ------------------------ |
122
+ | ua-parser | 63.8% |
123
+ | user-agents | 60.1% |
124
+ | user-agent-parser | 31.9% (crashed on 5 UAs) |
125
+ | uarite | 79.9% |
126
+
127
+ The remaining uarite misses are mostly ancient tokenless crawler names and ordinary HTTP libraries, which are intentionally classified as scripts rather than bots.
128
+
129
+ ## Design
130
+
131
+ uarite uses a small hand-maintained regex/rule database rather than the much larger uap-core dataset. Rules are kept simple enough to audit directly and ordered so that specific identities such as crawlers or HarmonyOS win over browser compatibility tokens. The generic tells are few: a product token containing bot/spider/crawl/scan/verify/check, or an info URL in the UA — real browsers never carry one.
132
+
133
+ Unknown UAs remain visible: `pretty` falls back to the original string rather than discarding them, and malformed input never raises.
134
+
135
+ Some information simply is not present in a User-Agent. Modern Brave is normally indistinguishable from Chrome without browser-side detection, and iPhone UAs do not contain the device model. uarite prefers an incomplete answer to an invented one.
uarite-0.1.0/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # uarite
2
+
3
+ User-Agent parsing in Python has a long lineage. ua-parser is the official Python implementation of the ua-parser project, built around uap-core: the regex database extracted from BrowserScope's original parser and shared by implementations in many languages. user-agents wraps ua-parser with higher-level device and capability detection; its last release was in 2020. user-agent-parser is a separate implementation first released in 2022 and substantially updated in 2026, taking its own approach rather than building on uap-core. None of the three has further dependencies, but the regex databases weigh something: ua-parser installs at about 499 KB (531 KB with user-agents on top), user-agent-parser at 166 KB.
4
+
5
+ ```sh
6
+ uv add uarite
7
+ ```
8
+
9
+ This module is another take on the same problem: a small, dependency-free, compact pure-Python parser — 29 KB installed. It returns structured classifications, but also the thing most applications eventually need: a short human-readable description.
10
+
11
+ It is particularly aimed at server-side analytics, where correctly recognizing crawlers and modern reduced User-Agents matters. It detects disguised crawlers, distinguishes AI/search/preview traffic, handles HarmonyOS and bots without calling them Android, resolves common device model codes and falls back to reasonable output even when all else fails.
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from uarite import uaparse
17
+
18
+ r = uaparse("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
19
+ "(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36")
20
+
21
+ r.pretty # "Chrome/152 Windows"
22
+ r.kind # "browser"
23
+ r.bot # ""
24
+ r.url # ""
25
+
26
+ r = uaparse("Mozilla/5.0 (Linux; Android 13; Pixel 7) ... Chrome/134.0.6885.65 "
27
+ "Mobile Safari/537.36; compatible; facebookexternalhit/1.1; "
28
+ "+http://www.facebook.com/externalhit_uatext.php")
29
+
30
+ r.pretty # "Facebook"
31
+ r.kind # "preview"
32
+ r.bot # "Facebook"
33
+ r.url # "http://www.facebook.com/externalhit_uatext.php"
34
+ ```
35
+
36
+ ## Output
37
+
38
+ `uaparse(ua)` returns a frozen `UA` dataclass:
39
+
40
+ | Field | Content |
41
+ | -------- | ------------------------------------------------------------------------------------------------ |
42
+ | `pretty` | Compact display string (below); `""` for empty/missing UAs, the raw UA when unrecognized |
43
+ | `bot` | Crawler/previewer display name, or `""` |
44
+ | `kind` | `"browser"`, `"ai"`, `"search"`, `"preview"`, `"spider"`, or `""` (scripts/HTTP libraries) |
45
+ | `url` | The crawler's info URL (`+https://…` pointer), or `""`; not part of `pretty` — link it in the UI |
46
+
47
+ `kind` is `"browser"` for Mozilla-format UAs with no bot token, `"ai"` for training-data and AI-assistant fetchers (GPTBot, ClaudeBot, Google-Extended, ...), `"search"` for search-engine indexing (Googlebot, Bingbot, ...), `"preview"` for social link-preview fetchers (Facebook, WhatsApp, Slack, ...), `"spider"` for generic or unknown crawlers, and `""` for scripts and HTTP libraries.
48
+
49
+ `pretty` is intended to be shown directly:
50
+
51
+ - Desktop: `Chrome/152 Windows`, `Safari/18 macOS`
52
+ - iPhone/iPad: `iPhone iOS 17` — the device and iOS version, not Safari (the only browser iOS has)
53
+ - Android: `Chrome/118 Pixel 6`, or `Chrome/152 Android` when the device is unknown
54
+ - Crawlers: `GPTBot (AI)`, `Googlebot (search)`, `Facebook` — the kind suffix appears only where a provider runs crawlers of more than one kind; single-kind providers stay plain
55
+ - Scripts: `python-requests/2.32.5`, `pip/24.3.1 Linux`
56
+
57
+ Chrome's reduced Android UA reports the frozen values `Android 10; K`; neither is real device information, so uarite deliberately reports simply `Android`. HarmonyOS compatibility strings are similarly recognized before their misleading Android tokens.
58
+
59
+ ## Performance
60
+
61
+ All compared parsers cache repeated User-Agents, making cache hits effectively free. The useful difference is therefore the first parse of a new string.
62
+
63
+ In our benchmarks, uncached uarite parses take roughly **3–7 µs**. user-agent-parser is in the same general range at **~7 µs**, while the pure-Python ua-parser/user-agents path takes roughly **130–250 µs**.
64
+
65
+ The cache strategies differ in ways that matter under adversarial traffic. uarite caches only browser/client results (1024-entry LRU): crawlers tend to be unique and would otherwise evict the repeating UAs where caching is useful. user-agent-parser's 512-entry LRU lets a bot storm evict browsers, and user-agents' 200-entry dict clears entirely when full.
66
+
67
+ ## Accuracy
68
+
69
+ The main difference is not how many fields can be returned, but what the parser believes the User-Agent actually says.
70
+
71
+ For example, reduced Chrome does not really tell us that the device is named `K` or that it runs Android 10; an Android compatibility token does not make HarmonyOS Android; and a Facebook or Google crawler containing a plausible Chrome UA is still a crawler, not a Chrome visitor.
72
+
73
+ The table below compares representative results. uarite shows `r.pretty`; the ua-parser display strings are assembled from its structured output for comparison. user-agents is omitted: it shares the ua-parser backend and returns virtually identical data in a slightly different structure.
74
+
75
+ | Case | uarite¹ | ua-parser² |
76
+ | ---------------------------- | ------------------------- | --------------------------------------------- |
77
+ | Chrome, Windows | Chrome/152 Windows | Chrome/152 Windows |
78
+ | Safari, macOS | Safari/18 macOS | Safari/18 Mac OS X Mac |
79
+ | Opera, Linux | Opera/106 Linux | Opera/106 Linux |
80
+ | Chrome, Android (no model) | Chrome/152 Android | Chrome Mobile/152 Android K❌ |
81
+ | Chrome, Android (Pixel) | Chrome/118 Pixel 6 | Chrome Mobile/118 Android Pixel 6 |
82
+ | Edge, Android (model code) | Edge/110 Galaxy S7 | Edge Mobile/110 Android Samsung SM-G930P |
83
+ | Firefox, Android | Firefox/154 Android 15 | Firefox Mobile/154 Android Generic Smartphone |
84
+ | Safari, iPhone | iPhone iOS 17 | Mobile Safari/17 iOS iPhone |
85
+ | Huawei HarmonyOS phone | HuaweiBrowser/6 HarmonyOS | Huawei Browser/6 Android❌ Huawei Browser |
86
+ | GPTBot | GPTBot (AI) | GPTBot/1 Spider |
87
+ | Googlebot (disguised) | Googlebot (search) | Googlebot/2 Android❌ Spider |
88
+ | Facebook preview (disguised) | Facebook | FacebookBot/1 Android Pixel 7 ❌ |
89
+ | Meta crawler (disguised) | Meta | Chrome/145 Windows ❌ |
90
+ | WhatsApp preview | WhatsApp | WhatsApp/10 Spider |
91
+ | Bytespider | Bytespider | Bytespider/ Android❌ Generic Smartphone |
92
+ | BingPreview | BingPreview (preview) | BingPreview/1 Windows❌ Spider |
93
+ | AhrefsBot | AhrefsBot | AhrefsBot/7 Spider |
94
+ | python-requests | python-requests/2.32.5 | Python Requests/2 |
95
+
96
+ ❌ marks an incorrect browser, OS, or device interpretation.
97
+ ¹ `r.pretty` shown as is
98
+ ² `{user_agent.family}/{user_agent.major} {os.family} {device.family}`
99
+
100
+ Measured on 100 current browser UAs, family / version / OS accuracy is 80% / 91% / 99% for both ua-parser and user-agents, and 90% / 90% / 100% for uarite — its nominal "misses" are the iPhone rows, where it reports `iPhone iOS 17` rather than Mobile Safari, a deliberate choice since Safari is the only browser iOS has. user-agent-parser is absent from the table: it detected under a third of the crawlers in the test below and crashed on five inputs, so a side-by-side formatting comparison adds little.
101
+
102
+ Crawler detection was also tested against 2163 real-world crawler UAs from [monperrus/crawler-user-agents](https://github.com/monperrus/crawler-user-agents):
103
+
104
+ | Parser | Detected |
105
+ | ----------------- | ------------------------ |
106
+ | ua-parser | 63.8% |
107
+ | user-agents | 60.1% |
108
+ | user-agent-parser | 31.9% (crashed on 5 UAs) |
109
+ | uarite | 79.9% |
110
+
111
+ The remaining uarite misses are mostly ancient tokenless crawler names and ordinary HTTP libraries, which are intentionally classified as scripts rather than bots.
112
+
113
+ ## Design
114
+
115
+ uarite uses a small hand-maintained regex/rule database rather than the much larger uap-core dataset. Rules are kept simple enough to audit directly and ordered so that specific identities such as crawlers or HarmonyOS win over browser compatibility tokens. The generic tells are few: a product token containing bot/spider/crawl/scan/verify/check, or an info URL in the UA — real browsers never carry one.
116
+
117
+ Unknown UAs remain visible: `pretty` falls back to the original string rather than discarding them, and malformed input never raises.
118
+
119
+ Some information simply is not present in a User-Agent. Modern Brave is normally indistinguishable from Chrome without browser-side detection, and iPhone UAs do not contain the device model. uarite prefers an incomplete answer to an invented one.
@@ -0,0 +1 @@
1
+ *
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = [
3
+ "hatchling>=1.25.0",
4
+ "hatch-vcs>=0.4.0",
5
+ ]
6
+ build-backend = "hatchling.build"
7
+
8
+ [project]
9
+ name = "uarite"
10
+ dynamic = ["version"]
11
+ description = "Compact, human-readable User-Agent formatting and bot detection — no regex database, stdlib only"
12
+ authors = [
13
+ { name = "Leo Vasanko" },
14
+ ]
15
+ keywords = ["user-agent", "ua-parser"]
16
+ readme = "README.md"
17
+ requires-python = ">=3.10"
18
+ classifiers = [
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Internet :: WWW/HTTP",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Repository = "https://git.zi.fi/LeoVasanko/uarite"
29
+ Issues = "https://github.com/LeoVasanko/uarite"
30
+
31
+ [tool.hatch.version]
32
+ source = "vcs"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["uarite"]
@@ -0,0 +1,30 @@
1
+ # Benchmark scripts
2
+
3
+ Recreate every statistic and table quoted in the top-level README.
4
+ `uarite` must be importable (e.g. `pip install -e .` or run with
5
+ `PYTHONPATH` pointing at the repo root); the reference parsers are
6
+ benchmark-only dependencies, pulled ad hoc via `uv run --with`.
7
+
8
+ - `download_data.py` — fetch the external datasets into `data/`:
9
+ the 100 modern browser UAs (top-user-agents npm package) and the
10
+ crawler corpus (monperrus/crawler-user-agents). `data/ua.txt` is a
11
+ committed real-traffic sample, not downloaded.
12
+
13
+ ```
14
+ uv run scripts/download_data.py
15
+ ```
16
+
17
+ - `prettytable.py` — prints the accuracy-comparison markdown table:
18
+
19
+ ```
20
+ uv run --with ua-parser --with user-agents python scripts/prettytable.py
21
+ ```
22
+
23
+ - `bench.py` — prints browser-accuracy counts, crawler-detection rates,
24
+ URL-extraction coverage, and the timing tables (unique UAs, realistic
25
+ mix, bot storm) with cache statistics:
26
+
27
+ ```
28
+ uv run --with ua-parser --with user-agents --with user-agent-parser \
29
+ python scripts/bench.py
30
+ ```
@@ -0,0 +1,277 @@
1
+ """Benchmark uarite vs ua-parser vs user-agents vs user-agent-parser.
2
+
3
+ Reproduces the README's numbers: browser accuracy on 100 modern UAs,
4
+ crawler detection on 2163 real-world crawler UAs, and timing (unique UAs,
5
+ a realistic repeat/unique mix, a pure bot storm) with cache introspection.
6
+
7
+ Data lives in scripts/data (see download_data.py). Requires uarite
8
+ (installed) plus the benchmark-only reference parsers:
9
+
10
+ uv run --with ua-parser --with user-agents --with user-agent-parser \
11
+ python scripts/bench.py
12
+ """
13
+
14
+ import json
15
+ import random
16
+ import re
17
+ import timeit
18
+ from pathlib import Path
19
+
20
+ from ua_parser import parse as ua_parse
21
+ from user_agent_parser import parse as uap_parse
22
+ from user_agents import parse as uas_parse
23
+
24
+ from uarite import uaparse
25
+ from uarite.core import _parse_client
26
+
27
+ DATA = Path(__file__).parent / "data"
28
+ BROWSERS = json.loads((DATA / "top-user-agents.json").read_text())
29
+ CRAWLERS = json.loads((DATA / "crawler-user-agents.json").read_text())
30
+ OWN = (DATA / "ua.txt").read_text().splitlines()
31
+ CRAWLER_UAS = [ua for c in CRAWLERS for ua in (c.get("instances") or [c["pattern"]])]
32
+
33
+
34
+ def expected(ua):
35
+ """De-facto ground truth for real-browser UAs: (family, major, os)."""
36
+ fam = major = ""
37
+ for tok, name in (
38
+ ("EdgA", "Edge"),
39
+ ("Edg", "Edge"),
40
+ ("OPR", "Opera"),
41
+ ("SamsungBrowser", "Samsung Internet"),
42
+ ("Firefox", "Firefox"),
43
+ ("Chrome", "Chrome"),
44
+ ):
45
+ m = re.search(re.escape(tok) + r"/(\d+)", ua)
46
+ if m:
47
+ fam, major = name, m.group(1)
48
+ break
49
+ if not fam and "Safari/" in ua:
50
+ m = re.search(r"Version/(\d+)", ua)
51
+ fam, major = "Safari", m.group(1) if m else ""
52
+ if not fam and ("iPhone" in ua or "iPad" in ua):
53
+ fam = "Safari" # iOS webview UA: no browser token, Safari engine
54
+ if "iPhone" in ua or "iPad" in ua:
55
+ os = "ios"
56
+ elif re.search(r"Android [\d.]", ua):
57
+ os = "android"
58
+ elif "Windows NT" in ua:
59
+ os = "windows"
60
+ elif "Mac OS X" in ua:
61
+ os = "macos"
62
+ elif "Linux" in ua or "X11" in ua:
63
+ os = "linux"
64
+ else:
65
+ os = ""
66
+ return fam, major, os
67
+
68
+
69
+ def norm_os(s):
70
+ s = (s or "").lower().replace(" ", "").replace("_", "")
71
+ return {"macosx": "macos", "ubuntu": "linux"}.get(s, s)
72
+
73
+
74
+ def score_browsers():
75
+ res = {}
76
+ for name, fn in (
77
+ ("ua-parser", ua_parse),
78
+ ("user-agents", uas_parse),
79
+ ("user-agent-parser", uap_parse),
80
+ ):
81
+ fam_ok = ver_ok = os_ok = 0
82
+ for ua in BROWSERS:
83
+ efam, emaj, eos = expected(ua)
84
+ r = fn(ua)
85
+ if name == "ua-parser":
86
+ fam = r.user_agent.family or ""
87
+ maj = r.user_agent.major or ""
88
+ osf = r.os.family or ""
89
+ elif name == "user-agent-parser":
90
+ fam = r[0] or ""
91
+ maj = (r[1] or "").split(".")[0]
92
+ osf = r[2] or ""
93
+ else:
94
+ fam = r.browser.family or ""
95
+ maj = str(r.browser.version[0]) if r.browser.version else ""
96
+ osf = r.os.family or ""
97
+ fam = fam.split()[0]
98
+ fam_ok += efam.split()[0].lower() == fam.lower()
99
+ ver_ok += emaj == maj
100
+ os_ok += eos == norm_os(osf)
101
+ res[name] = (fam_ok, ver_ok, os_ok)
102
+ fam_ok = ver_ok = os_ok = 0
103
+ for ua in BROWSERS:
104
+ efam, emaj, eos = expected(ua)
105
+ r = uaparse(ua)
106
+ if emaj:
107
+ fam_ok += f"{efam}/{emaj}" in r.pretty
108
+ ver_ok += f"/{emaj}" in r.pretty
109
+ else:
110
+ # iOS webview: no version; "iPhone iOS 18" is the right answer
111
+ fam_ok += efam in r.pretty or "iPhone" in r.pretty or "iPad" in r.pretty
112
+ ver_ok += 1
113
+ oslabel = {
114
+ "ios": ("iPhone", "iPad"),
115
+ "macos": ("macOS",),
116
+ "windows": ("Windows",),
117
+ "linux": ("Linux",),
118
+ "android": ("Android",),
119
+ }.get(eos, ())
120
+ # Android with a known model drops the OS by design
121
+ model = re.search(r"Android [\d.]+; ([^;()]+?)(?:;|\))", ua)
122
+ has_model = eos == "android" and model and model.group(1) not in ("K", "Mobile")
123
+ os_ok += bool(any(os in r.pretty for os in oslabel) or has_model)
124
+ res["uarite"] = (fam_ok, ver_ok, os_ok)
125
+ return res
126
+
127
+
128
+ def score_crawlers():
129
+ out = {}
130
+ crashes = 0
131
+ for name in ("ua-parser", "user-agents", "user-agent-parser", "uarite"):
132
+ det = 0
133
+ for ua in CRAWLER_UAS:
134
+ try:
135
+ if name == "ua-parser":
136
+ r = ua_parse(ua)
137
+ fam = r.user_agent.family if r.user_agent else ""
138
+ bot = (r.device.family == "Spider" if r.device else False) or bool(
139
+ re.search(r"bot|spider|crawl", fam, re.I)
140
+ )
141
+ elif name == "user-agents":
142
+ bot = uas_parse(ua).is_bot
143
+ elif name == "user-agent-parser":
144
+ bot = uap_parse(ua)[4] == "Bot"
145
+ else:
146
+ bot = bool(uaparse(ua).bot)
147
+ except Exception:
148
+ crashes += name == "user-agent-parser"
149
+ continue
150
+ det += bot
151
+ out[name] = det
152
+ url_have = sum(1 for ua in CRAWLER_UAS if re.search(r"https?://", ua))
153
+ url_got = sum(1 for ua in CRAWLER_UAS if uaparse(ua).url)
154
+ return out, url_have, url_got, crashes
155
+
156
+
157
+ def realistic_mix():
158
+ """Realistic traffic: browsers repeat (weighted — popular combos many
159
+ times), interleaved with a steady stream of mostly-unique bot UAs that
160
+ hammer every parser's cache."""
161
+ rng = random.Random(42)
162
+ browsers = []
163
+ for i in range(2000):
164
+ # zipf-ish: early (popular) UAs repeat heavily
165
+ browsers.append(BROWSERS[int(100 * (rng.random() ** 3))])
166
+ bots = list(CRAWLER_UAS)
167
+ rng.shuffle(bots)
168
+ bots = bots[:1000]
169
+ # synthetic cache-busters: unique disguised bot UAs
170
+ for i in range(1000):
171
+ bots.append(
172
+ f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
173
+ f" (KHTML, like Gecko) Chrome/{100 + i % 50}.0.{i}.0 Safari/537.36;"
174
+ f" compatible; ScrapeBot{i}/1.{i}; +http://scrapebot{i}.example.com/"
175
+ )
176
+ mix = [None] * (len(browsers) + len(bots))
177
+ mix[::2] = browsers
178
+ mix[1::2] = bots
179
+ return mix
180
+
181
+
182
+ def bench_realistic():
183
+ mix = realistic_mix()
184
+ storm = [f"UniqueBot{i}/2.{i} (+http://ub{i}.example.com/)" for i in range(5000)]
185
+ print(
186
+ f"\n## realistic mix ({len(mix)} UAs: 2000 repeating browsers interleaved"
187
+ f" with 2000 mostly-unique bots)"
188
+ )
189
+ for name, fn in (
190
+ ("ua-parser", ua_parse),
191
+ ("user-agents", uas_parse),
192
+ ("user-agent-parser", uap_parse),
193
+ ("uarite", uaparse),
194
+ ):
195
+ fn = safe(fn)
196
+ t = timeit.timeit(lambda: [fn(u) for u in mix], number=1)
197
+ info = cache_info(name)
198
+ print(f"{name:20} {t / len(mix) * 1e6:7.1f} µs/UA cache: {info}")
199
+ print(f"\n## pure bot storm ({len(storm)} unique UAs, zero cache value)")
200
+ for name, fn in (
201
+ ("ua-parser", ua_parse),
202
+ ("user-agents", uas_parse),
203
+ ("user-agent-parser", uap_parse),
204
+ ("uarite", uaparse),
205
+ ):
206
+ fn = safe(fn)
207
+ t = timeit.timeit(lambda: [fn(u) for u in storm], number=1)
208
+ print(
209
+ f"{name:20} {t / len(storm) * 1e6:7.1f} µs/UA cache: {cache_info(name)}"
210
+ )
211
+
212
+
213
+ def safe(fn):
214
+ def f(u):
215
+ try:
216
+ return fn(u)
217
+ except Exception:
218
+ return None
219
+
220
+ return f
221
+
222
+
223
+ def cache_info(name):
224
+ if name == "uarite":
225
+ i = _parse_client.cache_info()
226
+ return f"{i.hits} hits / {i.misses} misses (cap 1024, browsers only)"
227
+ if name == "user-agent-parser":
228
+ from user_agent_parser.parser import _cached_parse_user_agent
229
+
230
+ i = _cached_parse_user_agent.cache_info()
231
+ return f"{i.hits} hits / {i.misses} misses (cap 512 LRU)"
232
+ if name == "user-agents":
233
+ from ua_parser.user_agent_parser import _PARSE_CACHE
234
+
235
+ return f"{len(_PARSE_CACHE)} entries (cap 200, CLEARS when full)"
236
+ if name == "ua-parser":
237
+ return "cap 2000 S3-FIFO (scan-resistant)"
238
+ return ""
239
+
240
+
241
+ def bench():
242
+ alluas = BROWSERS + CRAWLER_UAS + OWN
243
+ n = 3
244
+ res = {}
245
+ for name, fn in (
246
+ ("ua-parser", ua_parse),
247
+ ("user-agents", uas_parse),
248
+ ("user-agent-parser", uap_parse),
249
+ ("uarite", uaparse),
250
+ ):
251
+ fn = safe(fn)
252
+ t = timeit.timeit(lambda: [fn(u) for u in alluas], number=n)
253
+ res[name] = t / n / len(alluas) * 1e6
254
+ # warm cache: repeat a small realistic working set many times
255
+ working = (BROWSERS + OWN[:50]) * 10
256
+ t = timeit.timeit(lambda: [uaparse(u) for u in working], number=n)
257
+ res["uarite (warm cache)"] = t / n / len(working) * 1e6
258
+ return res
259
+
260
+
261
+ if __name__ == "__main__":
262
+ print(f"## browser accuracy (n={len(BROWSERS)}): family / version / OS correct")
263
+ for k, (f, v, o) in score_browsers().items():
264
+ print(f"{k:14} {f:3}/100 {v:3}/100 {o:3}/100")
265
+ det, url_have, url_got, crashes = score_crawlers()
266
+ print(f"\n## crawler detection (n={len(CRAWLER_UAS)})")
267
+ for k, v in det.items():
268
+ print(f"{k:18} {v:5} ({v / len(CRAWLER_UAS):.1%})")
269
+ print(f"user-agent-parser crashed on {crashes} UAs")
270
+ print(f"\nuarite URL extraction: {url_got}/{url_have} of instances carrying a URL")
271
+ print(
272
+ "\n## speed (µs per parse, mixed set of %d UAs)"
273
+ % (len(BROWSERS) + len(CRAWLER_UAS) + len(OWN))
274
+ )
275
+ for k, v in bench().items():
276
+ print(f"{k:20} {v:8.1f}")
277
+ bench_realistic()