Cache uaparse fully; drop the bot field and is_bot
This commit is contained in:
+93
-29
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user