Cache uaparse fully; drop the bot field and is_bot

This commit is contained in:
2026-09-08 23:23:02 +00:00
parent 84b9e27ced
commit 0bdbe9aaf9
4 changed files with 177 additions and 46 deletions
+93 -29
View File
@@ -1,14 +1,25 @@
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "ua-parser[re2,regex]>=1.0.2",
# "uarite",
# "user-agent-parser>=0.2.1",
# "user-agents>=2.2.0",
# ]
#
# [tool.uv.sources]
# uarite = { path = "..", editable = true }
# ///
"""Benchmark uarite vs ua-parser vs user-agents vs user-agent-parser.
Reproduces the README's numbers: browser accuracy on 100 modern UAs,
crawler detection on 2163 real-world crawler UAs, and timing (unique UAs,
a realistic repeat/unique mix, a pure bot storm) with cache introspection.
Data lives in scripts/data (see download_data.py). Requires uarite
(installed) plus the benchmark-only reference parsers:
Data lives in scripts/data (see download_data.py). All dependencies,
including uarite itself (editable), are declared inline:
uv run --with ua-parser --with user-agents --with user-agent-parser \
python scripts/bench.py
uv run scripts/bench.py
"""
import json
@@ -17,17 +28,55 @@ import re
import timeit
from pathlib import Path
import ua_parser
from ua_parser import parse as ua_parse
from user_agent_parser import parse as uap_parse
from user_agents import parse as uas_parse
from uarite import uaparse
from uarite.core import _parse_client
ALL_DOMAINS = (
ua_parser.Domain.USER_AGENT | ua_parser.Domain.OS | ua_parser.Domain.DEVICE
)
_VARIANTS = {}
def ua_variant(name):
"""ua-parser with a specific resolver backend (pure/re2/rust), lazily
built so its one-time database load lands in the untimed warm-up call.
The default parse() picks whichever native backend is installed, so
backends must be forced explicitly to benchmark them separately."""
if name not in _VARIANTS:
ctor = {
"pure": ua_parser.BasicResolver,
"re2": ua_parser.Re2Resolver,
"rust": ua_parser.RegexResolver,
}[name]
parser = ua_parser.Parser(
ua_parser.CachingResolver(
ctor(ua_parser.load_builtins()), ua_parser.Cache(2000)
)
)
_VARIANTS[name] = lambda ua: parser(ua, ALL_DOMAINS)
return _VARIANTS[name]
def uap_pure(ua):
return ua_variant("pure")(ua)
def uap_re2(ua):
return ua_variant("re2")(ua)
def uap_rust(ua):
return ua_variant("rust")(ua)
DATA = Path(__file__).parent / "data"
BROWSERS = json.loads((DATA / "top-user-agents.json").read_text())
CRAWLERS = json.loads((DATA / "crawler-user-agents.json").read_text())
OWN = (DATA / "ua.txt").read_text().splitlines()
CRAWLER_UAS = [ua for c in CRAWLERS for ua in (c.get("instances") or [c["pattern"]])]
@@ -149,7 +198,9 @@ def score_crawlers():
elif name == "user-agent-parser":
bot = uap_parse(ua)[4] == "Bot"
else:
bot = bool(uaparse(ua).bot)
# Anything not recognized as a real browser is automated:
# known bots, generic spiders, clients, spoofed claims.
bot = uaparse(ua).kind != "browser"
except Exception:
crashes += name == "user-agent-parser"
continue
@@ -193,7 +244,9 @@ def bench_realistic():
f" with 2000 mostly-unique bots)"
)
for name, fn in (
("ua-parser", ua_parse),
("ua-parser (pure)", uap_pure),
("ua-parser (re2)", uap_re2),
("ua-parser (rust)", uap_rust),
("user-agents", uas_parse),
("user-agent-parser", uap_parse),
("uarite", uaparse),
@@ -204,7 +257,9 @@ def bench_realistic():
print(f"{name:20} {t / len(mix) * 1e6:7.1f} µs/UA cache: {info}")
print(f"\n## pure bot storm ({len(storm)} unique UAs, zero cache value)")
for name, fn in (
("ua-parser", ua_parse),
("ua-parser (pure)", uap_pure),
("ua-parser (re2)", uap_re2),
("ua-parser (rust)", uap_rust),
("user-agents", uas_parse),
("user-agent-parser", uap_parse),
("uarite", uaparse),
@@ -228,8 +283,8 @@ def safe(fn):
def cache_info(name):
if name == "uarite":
i = _parse_client.cache_info()
return f"{i.hits} hits / {i.misses} misses (cap 1024, browsers only)"
i = uaparse.cache_info()
return f"{i.hits} hits / {i.misses} misses (cap 1024)"
if name == "user-agent-parser":
from user_agent_parser.parser import _cached_parse_user_agent
@@ -239,33 +294,48 @@ def cache_info(name):
from ua_parser.user_agent_parser import _PARSE_CACHE
return f"{len(_PARSE_CACHE)} entries (cap 200, CLEARS when full)"
if name == "ua-parser":
if name.startswith("ua-parser"):
return "cap 2000 S3-FIFO (scan-resistant)"
return ""
def bench():
alluas = BROWSERS + CRAWLER_UAS + OWN
n = 3
"""Cold-cache speed: a single pass over previously unseen UAs, with
equal shares of realistic browser and crawler strings since they take
different parse paths. Runs before the accuracy passes, which would
otherwise warm every parser's cache with these very strings.
Each parser first parses one dummy UA (untimed) so that lazy regex
compilation and database loading do not land on the first real item —
ua-parser's first parse alone costs ~59 ms loading its database. The
cache gains nothing from it since all timed UAs are unique."""
rng = random.Random(7)
work = BROWSERS + rng.sample(CRAWLER_UAS, len(BROWSERS))
rng.shuffle(work)
res = {}
for name, fn in (
("ua-parser", ua_parse),
("ua-parser (pure)", uap_pure),
("ua-parser (re2)", uap_re2),
("ua-parser (rust)", uap_rust),
("user-agents", uas_parse),
("user-agent-parser", uap_parse),
("uarite", uaparse),
):
fn = safe(fn)
t = timeit.timeit(lambda: [fn(u) for u in alluas], number=n)
res[name] = t / n / len(alluas) * 1e6
# warm cache: repeat a small realistic working set many times
working = (BROWSERS + OWN[:50]) * 10
t = timeit.timeit(lambda: [uaparse(u) for u in working], number=n)
res["uarite (warm cache)"] = t / n / len(working) * 1e6
return res
fn("Warmup/1.0 (+https://example.com/warmup)")
uaparse.cache_clear()
t = timeit.timeit(lambda: [fn(u) for u in work], number=1)
res[name] = t / len(work) * 1e6
return res, len(work)
if __name__ == "__main__":
print(f"## browser accuracy (n={len(BROWSERS)}): family / version / OS correct")
res, nwork = bench()
print(f"## speed (µs per cold parse, {nwork} unique UAs,"
" half browsers / half crawlers)")
for k, v in res.items():
print(f"{k:20} {v:8.1f}")
print(f"\n## browser accuracy (n={len(BROWSERS)}): family / version / OS correct")
for k, (f, v, o) in score_browsers().items():
print(f"{k:14} {f:3}/100 {v:3}/100 {o:3}/100")
det, url_have, url_got, crashes = score_crawlers()
@@ -274,10 +344,4 @@ if __name__ == "__main__":
print(f"{k:18} {v:5} ({v / len(CRAWLER_UAS):.1%})")
print(f"user-agent-parser crashed on {crashes} UAs")
print(f"\nuarite URL extraction: {url_got}/{url_have} of instances carrying a URL")
print(
"\n## speed (µs per parse, mixed set of %d UAs)"
% (len(BROWSERS) + len(CRAWLER_UAS) + len(OWN))
)
for k, v in bench().items():
print(f"{k:20} {v:8.1f}")
bench_realistic()
+67
View File
@@ -0,0 +1,67 @@
# /// script
# requires-python = ">=3.14"
# dependencies = ["matplotlib"]
# ///
"""Bar chart of cold-parse throughput for the README's Performance section.
Numbers are pasted from `uv run scripts/bench.py` (the cold 50/50
browser/crawler mix, µs per parse) and shown as parses per second.
Transparent SVG, text baked to paths, neutral grays: renders the same
on light and dark themes. All labels sit on the bars themselves.
Very wide aspect ratio: forges render images at full content width,
so height alone controls how tall it appears.
uv run scripts/speedplot.py
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
matplotlib.rcParams["svg.fonttype"] = "path" # text as paths: renders anywhere
import matplotlib.pyplot as plt # noqa: E402
# µs per cold parse, from scripts/bench.py.
US = {
"user-agent-parser": 8.2,
"uarite": 17.7,
"ua-parser (Rust)": 46.6,
"ua-parser (RE2)": 77.1,
"ua-parser (pure)": 321.5,
"user-agents": 334.4,
}
#: Readable on both white and dark backgrounds.
OUTSIDE = "#767676"
data = sorted(((n, 1e6 / us) for n, us in US.items()), key=lambda t: -t[1])
names = [n for n, _ in data][::-1]
values = [v for _, v in data][::-1]
colors = ["#6e6e6e"] * len(data)
colors[names.index("uarite")] = "#2b6cb0"
fig, ax = plt.subplots(figsize=(12, 1.7), dpi=100)
bars = ax.barh(names, values, color=colors, height=0.62)
ax.set_xlim(0, max(values))
ax.axis("off")
for bar, name, v in zip(bars, names, values):
# Round to three significant digits: 121951 -> "122 000".
rounded = round(v, 2 - int(f"{v:.0e}".split("e")[1]))
label = f"{name} {rounded:,.0f}".replace(",", " ")
y = bar.get_y() + bar.get_height() / 2
if bar.get_width() > max(values) * 0.28:
# Long bar: white text inside, right-aligned at the bar end.
ax.text(bar.get_width() - max(values) * 0.012, y, label,
va="center", ha="right", color="white", fontsize=11)
else:
# Short bar: theme-neutral gray text just past the bar end.
ax.text(bar.get_width() + max(values) * 0.012, y, label,
va="center", color=OUTSIDE, fontsize=11)
fig.tight_layout(pad=0.2)
out = Path("docs/bench-speed.svg")
out.parent.mkdir(exist_ok=True)
fig.savefig(out, transparent=True)
print("wrote", out)