Updated docs with fastuaparser and new benchmarks, cleaner structure.
This commit is contained in:
+2
-2
@@ -17,7 +17,7 @@ benchmark-only dependencies, pulled ad hoc via `uv run --with`.
|
||||
- `prettytable.py` — prints the accuracy-comparison markdown table:
|
||||
|
||||
```
|
||||
uv run --with ua-parser --with user-agents python scripts/prettytable.py
|
||||
uv run --with ua-parser --with fastuaparser python scripts/prettytable.py
|
||||
```
|
||||
|
||||
- `bench.py` — prints browser-accuracy counts, crawler-detection rates,
|
||||
@@ -25,6 +25,6 @@ benchmark-only dependencies, pulled ad hoc via `uv run --with`.
|
||||
mix, bot storm) with cache statistics:
|
||||
|
||||
```
|
||||
uv run --with ua-parser --with user-agents --with user-agent-parser \
|
||||
uv run --with ua-parser --with user-agents --with fastuaparser \
|
||||
python scripts/bench.py
|
||||
```
|
||||
|
||||
+43
-23
@@ -1,16 +1,16 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.14"
|
||||
# dependencies = [
|
||||
# "fastuaparser>=0.1.4",
|
||||
# "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.
|
||||
"""Benchmark uarite vs ua-parser vs user-agents vs fastuaparser.
|
||||
|
||||
Reproduces the README's numbers: browser accuracy on 100 modern UAs,
|
||||
crawler detection on 2163 real-world crawler UAs, and timing (unique UAs,
|
||||
@@ -29,8 +29,8 @@ import timeit
|
||||
from pathlib import Path
|
||||
|
||||
import ua_parser
|
||||
from fastuaparser import parse_ua as fua_parse
|
||||
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
|
||||
@@ -74,6 +74,7 @@ def uap_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())
|
||||
@@ -125,7 +126,6 @@ def score_browsers():
|
||||
for name, fn in (
|
||||
("ua-parser", ua_parse),
|
||||
("user-agents", uas_parse),
|
||||
("user-agent-parser", uap_parse),
|
||||
):
|
||||
fam_ok = ver_ok = os_ok = 0
|
||||
for ua in BROWSERS:
|
||||
@@ -135,10 +135,6 @@ def score_browsers():
|
||||
fam = r.user_agent.family or ""
|
||||
maj = r.user_agent.major or ""
|
||||
osf = r.os.family or ""
|
||||
elif name == "user-agent-parser":
|
||||
fam = r[0] or ""
|
||||
maj = (r[1] or "").split(".")[0]
|
||||
osf = r[2] or ""
|
||||
else:
|
||||
fam = r.browser.family or ""
|
||||
maj = str(r.browser.version[0]) if r.browser.version else ""
|
||||
@@ -148,6 +144,29 @@ def score_browsers():
|
||||
ver_ok += emaj == maj
|
||||
os_ok += eos == norm_os(osf)
|
||||
res[name] = (fam_ok, ver_ok, os_ok)
|
||||
# fastuaparser returns one pretty string ("Chrome - Windows") and no
|
||||
# version numbers, so score it on the substrings it does produce.
|
||||
fam_ok = ver_ok = os_ok = 0
|
||||
for ua in BROWSERS:
|
||||
efam, emaj, eos = expected(ua)
|
||||
pretty = fua_parse(ua)
|
||||
fam_ok += efam.split()[0].lower() in pretty.lower()
|
||||
ver_ok += bool(emaj) and emaj in pretty
|
||||
os_ok += bool(
|
||||
re.search(
|
||||
{
|
||||
"ios": r"iOS|iPhone|iPad",
|
||||
"macos": r"Mac",
|
||||
"windows": r"Windows",
|
||||
"linux": r"Linux",
|
||||
"android": r"Android",
|
||||
}[eos],
|
||||
pretty,
|
||||
)
|
||||
if eos
|
||||
else True
|
||||
)
|
||||
res["fastuaparser"] = (fam_ok, ver_ok, os_ok)
|
||||
fam_ok = ver_ok = os_ok = 0
|
||||
for ua in BROWSERS:
|
||||
efam, emaj, eos = expected(ua)
|
||||
@@ -183,7 +202,7 @@ def score_browsers():
|
||||
def score_crawlers():
|
||||
out = {}
|
||||
crashes = 0
|
||||
for name in ("ua-parser", "user-agents", "user-agent-parser", "uarite"):
|
||||
for name in ("ua-parser", "user-agents", "fastuaparser", "uarite"):
|
||||
det = 0
|
||||
for ua in CRAWLER_UAS:
|
||||
try:
|
||||
@@ -195,14 +214,16 @@ def score_crawlers():
|
||||
)
|
||||
elif name == "user-agents":
|
||||
bot = uas_parse(ua).is_bot
|
||||
elif name == "user-agent-parser":
|
||||
bot = uap_parse(ua)[4] == "Bot"
|
||||
elif name == "fastuaparser":
|
||||
# "Bot" is a real verdict; "Other" is a non-browser
|
||||
# client (wget etc.), which is automated traffic too.
|
||||
bot = fua_parse(ua).split(" - ")[0] in ("Bot", "Other")
|
||||
else:
|
||||
# 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"
|
||||
crashes += name == "fastuaparser"
|
||||
continue
|
||||
det += bot
|
||||
out[name] = det
|
||||
@@ -248,7 +269,7 @@ def bench_realistic():
|
||||
("ua-parser (re2)", uap_re2),
|
||||
("ua-parser (rust)", uap_rust),
|
||||
("user-agents", uas_parse),
|
||||
("user-agent-parser", uap_parse),
|
||||
("fastuaparser", fua_parse),
|
||||
("uarite", uaparse),
|
||||
):
|
||||
fn = safe(fn)
|
||||
@@ -261,7 +282,7 @@ def bench_realistic():
|
||||
("ua-parser (re2)", uap_re2),
|
||||
("ua-parser (rust)", uap_rust),
|
||||
("user-agents", uas_parse),
|
||||
("user-agent-parser", uap_parse),
|
||||
("fastuaparser", fua_parse),
|
||||
("uarite", uaparse),
|
||||
):
|
||||
fn = safe(fn)
|
||||
@@ -285,11 +306,8 @@ def cache_info(name):
|
||||
if name == "uarite":
|
||||
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
|
||||
|
||||
i = _cached_parse_user_agent.cache_info()
|
||||
return f"{i.hits} hits / {i.misses} misses (cap 512 LRU)"
|
||||
if name == "fastuaparser":
|
||||
return "none (branch parser, ~1 µs flat)"
|
||||
if name == "user-agents":
|
||||
from ua_parser.user_agent_parser import _PARSE_CACHE
|
||||
|
||||
@@ -318,7 +336,7 @@ def bench():
|
||||
("ua-parser (re2)", uap_re2),
|
||||
("ua-parser (rust)", uap_rust),
|
||||
("user-agents", uas_parse),
|
||||
("user-agent-parser", uap_parse),
|
||||
("fastuaparser", fua_parse),
|
||||
("uarite", uaparse),
|
||||
):
|
||||
fn = safe(fn)
|
||||
@@ -331,8 +349,10 @@ def bench():
|
||||
|
||||
if __name__ == "__main__":
|
||||
res, nwork = bench()
|
||||
print(f"## speed (µs per cold parse, {nwork} unique UAs,"
|
||||
" half browsers / half crawlers)")
|
||||
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")
|
||||
@@ -342,6 +362,6 @@ if __name__ == "__main__":
|
||||
print(f"\n## crawler detection (n={len(CRAWLER_UAS)})")
|
||||
for k, v in det.items():
|
||||
print(f"{k:18} {v:5} ({v / len(CRAWLER_UAS):.1%})")
|
||||
print(f"user-agent-parser crashed on {crashes} UAs")
|
||||
print(f"fastuaparser crashed on {crashes} UAs")
|
||||
print(f"\nuarite URL extraction: {url_got}/{url_have} of instances carrying a URL")
|
||||
bench_realistic()
|
||||
|
||||
+8
-15
@@ -1,15 +1,15 @@
|
||||
"""Print the README's accuracy-comparison table as markdown.
|
||||
|
||||
uarite's column is simply `r.pretty`. The reference modules have no
|
||||
display format; their columns use one plain format string each on their
|
||||
structured output (footnotes ²³ in the README). Requires uarite (installed)
|
||||
plus the benchmark-only reference parsers:
|
||||
uarite's and fastuaparser's columns are simply their pretty-string output.
|
||||
ua-parser has no display format; its column uses one plain format string
|
||||
on the structured output (footnotes ¹² in the README). Requires uarite
|
||||
(installed) plus the benchmark-only reference parsers:
|
||||
|
||||
uv run --with ua-parser --with user-agents python scripts/prettytable.py
|
||||
uv run --with ua-parser --with fastuaparser python scripts/prettytable.py
|
||||
"""
|
||||
|
||||
from fastuaparser import parse_ua as fua_parse
|
||||
from ua_parser import parse as ua_parse
|
||||
from user_agents import parse as uas_parse
|
||||
|
||||
from uarite import uaparse
|
||||
|
||||
@@ -86,15 +86,8 @@ def imitate_uaparser(ua):
|
||||
return f"{fam}/{maj} {osf} {dev}"
|
||||
|
||||
|
||||
def imitate_useragents(ua):
|
||||
r = uas_parse(ua)
|
||||
fam = r.browser.family or ""
|
||||
maj = str(r.browser.version[0]) if r.browser.version else ""
|
||||
return f"{fam}/{maj} {r.os.family or ''} {r.device.family or ''}"
|
||||
|
||||
|
||||
print("| Case | uarite¹ | ua-parser² | user-agents³ |")
|
||||
print("| Case | uarite¹ | fastuaparser² | ua-parser³ |")
|
||||
print("|---|---|---|---|")
|
||||
for name, ua in CASES:
|
||||
ours = uaparse(ua).pretty or "—"
|
||||
print(f"| {name} | {ours} | {imitate_uaparser(ua)} | {imitate_useragents(ua)} |")
|
||||
print(f"| {name} | {ours} | {fua_parse(ua)} | {imitate_uaparser(ua)} |")
|
||||
|
||||
+17
-19
@@ -23,13 +23,14 @@ 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.
|
||||
# fastuaparser (~1 µs, 1M parses/s) and cached results are too far off
|
||||
# this scale to draw meaningfully and are only mentioned in the README.
|
||||
US = {
|
||||
"user-agent-parser": 8.2,
|
||||
"uarite": 17.7,
|
||||
"ua-parser (Rust)": 46.6,
|
||||
"ua-parser (RE2)": 77.1,
|
||||
"ua-parser": 321.5,
|
||||
"user-agents": 334.4,
|
||||
"uarite": 18.0,
|
||||
"ua-parser (Rust)": 38.1,
|
||||
"ua-parser (RE2)": 71.7,
|
||||
"ua-parser": 304.7,
|
||||
"user-agents": 324.6,
|
||||
}
|
||||
|
||||
#: Readable on both white and dark backgrounds.
|
||||
@@ -41,29 +42,26 @@ 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)
|
||||
fig, ax = plt.subplots(figsize=(12, 1.5), dpi=100)
|
||||
bars = ax.barh(names, values, color=colors, height=0.82)
|
||||
ax.set_xlim(0, max(values))
|
||||
xmax = max(values)
|
||||
ax.set_xlim(0, xmax)
|
||||
ax.axis("off")
|
||||
|
||||
for bar, name, v in zip(bars, names, values):
|
||||
# Round to two significant digits: 121951 -> "120 000".
|
||||
rounded = round(v, 1 - int(f"{v:.0e}".split("e")[1]))
|
||||
label = f"{name} {rounded:,.0f}".replace(",", " ")
|
||||
label = f"{rounded:,.0f} {name}".replace(",", " ")
|
||||
# va="center" centers the font bbox incl. descender space, which leaves
|
||||
# the glyphs slightly high; nudge down to optically center on the bar.
|
||||
y = bar.get_y() + bar.get_height() / 2 - 0.09
|
||||
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)
|
||||
# All labels after the bar: theme-neutral gray, number before name.
|
||||
# The longest bar's label overflows the axes; bbox_inches="tight"
|
||||
# below expands the canvas to include it, so no dead space remains.
|
||||
ax.text(bar.get_width() + xmax * 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)
|
||||
fig.savefig(out, transparent=True, bbox_inches="tight", pad_inches=0.02)
|
||||
print("wrote", out)
|
||||
|
||||
Reference in New Issue
Block a user