Limen.Signal.UserAgent (limen v0.2.1)

Copy Markdown View Source

A deliberately small user agent classifier.

It answers the questions bot scoring needs, not the ones analytics does: does the agent claim to be a browser, which engine (and therefore which headers it must send), or which kind of automated client, and which one (for DNS verification, and for rules about a particular one)?

Families

Browsers are :chrome, :edge, :opera, :samsung, :firefox and :safari. Automated clients that name themselves are:

  • :crawler - search engines, SEO tools, and anything else calling itself a bot, crawler or spider;
  • :ai_crawler - crawlers and fetchers of AI companies, gathering training data or answering their users' questions, such as GPTBot, ClaudeBot, CCBot or Bytespider, after the community list at ai.robots.txt;
  • :link_preview - services fetching a shared link to show a preview, such as facebookexternalhit, Slackbot, Discordbot or WhatsApp;
  • :headless - browsers driven by automation;
  • :tool - HTTP libraries and command line tools.

Anything else is :other, and a missing or empty user agent is :none. Known automated clients also get a name, such as "googlebot" or "gptbot". Anyone can claim any of these; Limen.Signal.Fcrdns verifies the claims that can be verified.

The lists are built in and kept current with Limen's releases. The :user_agents option of Limen.Config extends and overrides them per instance, by family, with the tokens to look for, matched exactly as they appear in user agents (case matters):

config :my_app, Limen,
  user_agents: [
    ai_crawler: ["NewAIBot"],
    link_preview: [{"ChatUnfurler/", "chat-unfurler"}],
    crawler: ["Amazonbot"],
    ignore: ["Mastodon/"]
  ]

A token given as a string is named after itself, in lowercase and without a trailing /; {token, name} names it explicitly. Listing a built-in token under another family moves it there, and :ignore removes built-in tokens altogether.

How it works

Non-browser markers and browser tokens are found with a single pass of a precompiled Aho-Corasick pattern, which matches many strings at once, and platforms with a second one: a pass reports matches that do not overlap, and platform names can overlap markers, as Windows does WindowsPowerShell/. The leftmost marker wins, so a crawler that embeds a Chrome token in its user agent is still a crawler. An instance with tokens of its own looks for them in a third pass.

Summary

Types

An instance's own tokens (see the :user_agents option), as Limen.Config prepares them.

t()

Functions

Classifies a user agent string with the built-in lists only.

Classifies a user agent string with the built-in lists and an instance's own tokens (its config.user_agents).

Types

custom()

@type custom() :: %{
  markers: %{
    required(String.t()) => {:marker, family(), String.t() | nil} | :ignore
  },
  pattern: :binary.cp() | nil
}

An instance's own tokens (see the :user_agents option), as Limen.Config prepares them.

family()

@type family() ::
  :chrome
  | :edge
  | :opera
  | :samsung
  | :firefox
  | :safari
  | :crawler
  | :ai_crawler
  | :link_preview
  | :headless
  | :tool
  | :other
  | :none

t()

@type t() :: %{
  family: family(),
  name: String.t() | nil,
  version: non_neg_integer() | nil,
  engine: :chromium | :gecko | :webkit | nil,
  platform: :windows | :macos | :linux | :android | :ios | :chromeos | nil,
  mobile: boolean()
}

Functions

parse(ua)

@spec parse(String.t() | nil) :: t()

Classifies a user agent string with the built-in lists only.

iex> Limen.Signal.UserAgent.parse("curl/8.5.0")
%{family: :tool, name: "curl", version: nil, engine: nil, platform: nil, mobile: false}

iex> ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " <>
...>   "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
iex> Limen.Signal.UserAgent.parse(ua)
%{family: :chrome, name: nil, version: 128, engine: :chromium, platform: :windows, mobile: false}

parse(ua, custom)

@spec parse(String.t() | nil, custom() | nil) :: t()

Classifies a user agent string with the built-in lists and an instance's own tokens (its config.user_agents).

iex> custom = Limen.Config.build(user_agents: [tool: ["MyMonitor/"]]).user_agents
iex> Limen.Signal.UserAgent.parse("MyMonitor/2.0", custom).family
:tool