agentry 3.0.0 → 3.1.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -1
- data/README.md +213 -420
- data/lib/browser/bot/ip_ranges.rb +105 -15
- data/lib/browser/cloudflare.rb +3 -1
- data/lib/browser/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca414103cb27f143a4872d36801bd26e012ff0950552ac96646497be9a2bb8f3
|
|
4
|
+
data.tar.gz: 38d3bb2c5529a324160476b7c5874d645689bb81409234e14ba2ed7f01da4d61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 755ee15fab59790728e25954113018ca3d25fb1255933a6a029f6dabcdc2767d5781649592c88f0ba6f554f2772319d318df86b9a6009a31eba9926764f01bdf
|
|
7
|
+
data.tar.gz: ce39ec99a84d2326c2575c3a9ae8c76c3e2205283f4cf51d8b7f7ea5e6b1429e603078e2aac99947970323c89d0ba86902f32aebf014d76936c7d08edadfe215
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [3.1.0] — 2026-11-06
|
|
6
|
+
|
|
7
|
+
A Google Cloud VM no longer passes as a verified Googlebot, and a forged Googlebot can no longer stall requests on reverse DNS.
|
|
8
|
+
|
|
9
|
+
**New**
|
|
10
|
+
- `Browser::Bot::IPRanges.dns_cache_size` caches each reverse DNS verdict per owner and IP, failures included, for `dns_cache_ttl` seconds (3,600 by default), so a forged Googlebot pays its DNS wait once per TTL instead of on every request. It is 0, off, by default.
|
|
11
|
+
- `dns_timeout` sets the timeout per query and per nameserver, as `Resolv` applies it. Invalid values raise `ArgumentError`.
|
|
12
|
+
|
|
13
|
+
**Fixed**
|
|
14
|
+
- Google's reverse DNS check accepts only `googlebot.com` and `google.com`. Any Google Cloud VM, named under `googleusercontent.com`, passed as a verified Googlebot; Google's user-triggered fetchers are covered by the IP ranges it publishes.
|
|
15
|
+
- The DNS timeout drops from 2 seconds to 1, and the forward lookup uses an absolute name, so the resolver never retries it under the search domains of `/etc/resolv.conf`.
|
|
16
|
+
- `IPRanges.refresh` and the weekly sync raise `Browser::Error` on anything implausible, and keep the current ranges: a prefix broader than /12 (IPv4) or /24 (IPv6), a private, loopback, link-local, multicast or unspecified range, a malformed document, or a redirect that leaves HTTPS or the owner's host.
|
|
17
|
+
- Cloudflare header values over 128 bytes are ignored.
|
|
18
|
+
|
|
5
19
|
## [3.0.0] — 2026-11-05
|
|
6
20
|
|
|
7
21
|
The gem is renamed Agentry and lives at levups/agentry; the API is untouched.
|
|
@@ -86,7 +100,8 @@ First release, forked from browser 6.2.0 (`dd88933`). Same API: `gem "agentry"`
|
|
|
86
100
|
- A corpus of 20,328 user agents, with snapshot and diff scripts proving answers match upstream, and a
|
|
87
101
|
request benchmark.
|
|
88
102
|
|
|
89
|
-
[Unreleased]: https://github.com/levups/agentry/compare/v3.
|
|
103
|
+
[Unreleased]: https://github.com/levups/agentry/compare/v3.1.0...HEAD
|
|
104
|
+
[3.1.0]: https://github.com/levups/agentry/compare/v3.0.0...v3.1.0
|
|
90
105
|
[3.0.0]: https://github.com/levups/agentry/compare/v2.1.0...v3.0.0
|
|
91
106
|
[2.1.0]: https://github.com/levups/agentry/compare/v2.0.0...v2.1.0
|
|
92
107
|
[2.0.0]: https://github.com/levups/agentry/compare/v1.0.0...v2.0.0
|
data/README.md
CHANGED
|
@@ -1,432 +1,211 @@
|
|
|
1
1
|
# Agentry
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
3
|
+
Agentry is a Ruby gem that tells you who sent an HTTP request: which browser and version, on which operating system
|
|
4
|
+
and device, or which bot. It reads the `User-Agent` header, a line of text every browser and crawler sends with each
|
|
5
|
+
request, such as `Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) … Version/18.1 Mobile/15E148
|
|
6
|
+
Safari/604.1`. Each browser and crawler writes it in its own way, and Agentry knows the formats so that your code
|
|
7
|
+
can ask plain questions such as `browser.bot?` or `browser.device.mobile?`.
|
|
8
|
+
|
|
9
|
+
Apps use the answers to leave bots out of their analytics, to turn crawlers away, to adapt a page to phones, or to
|
|
10
|
+
warn visitors on an old browser. Agentry works in any Ruby program, and gives Rails controllers and views a
|
|
11
|
+
`browser` helper for the current request.
|
|
12
|
+
|
|
13
|
+
Website and guide: https://levups.github.io/agentry/
|
|
14
|
+
|
|
15
|
+
## Why Agentry
|
|
16
|
+
|
|
17
|
+
Agentry is a fork of [browser](https://github.com/fnando/browser), the gem Nando Vieira wrote, which has not changed
|
|
18
|
+
since June 2025. Code written for browser runs on Agentry as it is: same `Browser` class, same `require "browser"`,
|
|
19
|
+
same methods. Here is what is different:
|
|
20
|
+
|
|
21
|
+
- It answers in 25 µs per request instead of 126 µs with browser 6.2.0, and in 2 µs for a user agent the process
|
|
22
|
+
has seen before (Ruby 4.0.9 with YJIT, see [Performance](#performance)).
|
|
23
|
+
- Its list of bots is updated every week. Of the 882 crawler user agents added to
|
|
24
|
+
[crawler-user-agents](https://github.com/monperrus/crawler-user-agents) since 2024 (test drivers and scanners
|
|
25
|
+
left out), browser misses 353 and Agentry 11.
|
|
26
|
+
- It says what kind of bot sent the request: an AI crawler, a search engine, an assistant fetching a page for a
|
|
27
|
+
person, a vulnerability scanner.
|
|
28
|
+
- It can check that a request claiming to be Googlebot, Bingbot or GPTBot comes from the IP addresses Google,
|
|
29
|
+
Microsoft or OpenAI publish for their crawlers.
|
|
30
|
+
- It reads the headers Cloudflare can add to a request, and the Client Hints Chromium browsers send, which tell
|
|
31
|
+
Windows 11 from Windows 10.
|
|
19
32
|
|
|
20
33
|
## Installation
|
|
21
34
|
|
|
22
|
-
|
|
23
|
-
gem "agentry"
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Usage
|
|
35
|
+
Agentry needs Ruby 3.3 or later. It is tested on Rails 8.0 and 8.1, and works without Rails. Add it to your Gemfile:
|
|
27
36
|
|
|
28
37
|
```ruby
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
browser = Browser.new("Some User Agent", accept_language: "en-us")
|
|
32
|
-
|
|
33
|
-
# General info
|
|
34
|
-
browser.bot?
|
|
35
|
-
browser.chrome?
|
|
36
|
-
browser.chromium_based?
|
|
37
|
-
browser.core_media?
|
|
38
|
-
browser.duck_duck_go?
|
|
39
|
-
browser.edge? # Newest MS browser
|
|
40
|
-
browser.electron? # Electron Framework
|
|
41
|
-
browser.firefox?
|
|
42
|
-
browser.full_version
|
|
43
|
-
browser.ie?
|
|
44
|
-
browser.ie?(6) # detect specific IE version
|
|
45
|
-
browser.ie?([">8", "<10"]) # detect specific IE (IE9).
|
|
46
|
-
browser.known? # has the browser been successfully detected?
|
|
47
|
-
browser.unknown? # the browser wasn't detected.
|
|
48
|
-
browser.meta # an array with several attributes
|
|
49
|
-
browser.name # readable browser name
|
|
50
|
-
browser.nokia?
|
|
51
|
-
browser.opera?
|
|
52
|
-
browser.opera_mini?
|
|
53
|
-
browser.phantom_js?
|
|
54
|
-
browser.quicktime?
|
|
55
|
-
browser.safari?
|
|
56
|
-
browser.safari_webapp_mode?
|
|
57
|
-
browser.samsung_browser?
|
|
58
|
-
browser.to_s # the meta info joined by space
|
|
59
|
-
browser.uc_browser?
|
|
60
|
-
browser.version # major version number
|
|
61
|
-
browser.webkit?
|
|
62
|
-
browser.webkit_full_version
|
|
63
|
-
browser.yandex?
|
|
64
|
-
browser.wechat?
|
|
65
|
-
browser.qq?
|
|
66
|
-
browser.weibo?
|
|
67
|
-
browser.sputnik?
|
|
68
|
-
browser.sougou_browser?
|
|
69
|
-
browser.epiphany?
|
|
70
|
-
|
|
71
|
-
# Get bot info
|
|
72
|
-
browser.bot.name
|
|
73
|
-
browser.bot.search_engine?
|
|
74
|
-
browser.bot.category # :ai_crawler, :search_engine_crawler, :scanner… (nil for a browser)
|
|
75
|
-
browser.bot.ai?
|
|
76
|
-
browser.bot.user_triggered? # an AI agent acting for a person right now (ChatGPT-User, Claude-User)
|
|
77
|
-
browser.bot?
|
|
78
|
-
browser.bot.why? # shows which matcher detected this user agent as a bot.
|
|
79
|
-
Browser::Bot.why?(ua)
|
|
80
|
-
|
|
81
|
-
# Get device info
|
|
82
|
-
browser.device
|
|
83
|
-
browser.device.id
|
|
84
|
-
browser.device.name
|
|
85
|
-
browser.device.unknown?
|
|
86
|
-
browser.device.blackberry_playbook?
|
|
87
|
-
browser.device.console?
|
|
88
|
-
browser.device.ipad?
|
|
89
|
-
browser.device.iphone?
|
|
90
|
-
browser.device.ipod_touch?
|
|
91
|
-
browser.device.kindle?
|
|
92
|
-
browser.device.kindle_fire?
|
|
93
|
-
browser.device.mobile?
|
|
94
|
-
browser.device.nintendo?
|
|
95
|
-
browser.device.playstation?
|
|
96
|
-
browser.device.ps3?
|
|
97
|
-
browser.device.ps4?
|
|
98
|
-
browser.device.psp?
|
|
99
|
-
browser.device.silk?
|
|
100
|
-
browser.device.surface?
|
|
101
|
-
browser.device.tablet?
|
|
102
|
-
browser.device.tv?
|
|
103
|
-
browser.device.vita?
|
|
104
|
-
browser.device.wii?
|
|
105
|
-
browser.device.wiiu?
|
|
106
|
-
browser.device.samsung?
|
|
107
|
-
browser.device.switch?
|
|
108
|
-
browser.device.xbox?
|
|
109
|
-
browser.device.xbox_360?
|
|
110
|
-
browser.device.xbox_one?
|
|
111
|
-
|
|
112
|
-
# Get platform info
|
|
113
|
-
browser.platform
|
|
114
|
-
browser.platform.id
|
|
115
|
-
browser.platform.name
|
|
116
|
-
browser.platform.version # e.g. 9 (for iOS9)
|
|
117
|
-
browser.platform.adobe_air?
|
|
118
|
-
browser.platform.android?
|
|
119
|
-
browser.platform.android?(4.2) # detect Android Jelly Bean 4.2
|
|
120
|
-
browser.platform.android_app? # detect webview in an Android app
|
|
121
|
-
browser.platform.android_webview? # alias for android_app?
|
|
122
|
-
browser.platform.blackberry?
|
|
123
|
-
browser.platform.blackberry?(10) # detect specific BlackBerry version
|
|
124
|
-
browser.platform.chrome_os?
|
|
125
|
-
browser.platform.firefox_os?
|
|
126
|
-
browser.platform.ios? # detect iOS
|
|
127
|
-
browser.platform.ios?(9) # detect specific iOS version
|
|
128
|
-
browser.platform.ios_app? # detect webview in an iOS app
|
|
129
|
-
browser.platform.ios_webview? # alias for ios_app?
|
|
130
|
-
browser.platform.linux?
|
|
131
|
-
browser.platform.mac?
|
|
132
|
-
browser.platform.unknown?
|
|
133
|
-
browser.platform.windows10?
|
|
134
|
-
browser.platform.windows7?
|
|
135
|
-
browser.platform.windows8?
|
|
136
|
-
browser.platform.windows8_1?
|
|
137
|
-
browser.platform.windows?
|
|
138
|
-
browser.platform.windows_mobile?
|
|
139
|
-
browser.platform.windows_phone?
|
|
140
|
-
browser.platform.windows_rt?
|
|
141
|
-
browser.platform.windows_touchscreen_desktop?
|
|
142
|
-
browser.platform.windows_vista?
|
|
143
|
-
browser.platform.windows_wow64?
|
|
144
|
-
browser.platform.windows_x64?
|
|
145
|
-
browser.platform.windows_x64_inclusive?
|
|
146
|
-
browser.platform.windows_xp?
|
|
147
|
-
browser.platform.kai_os?
|
|
38
|
+
gem "agentry"
|
|
148
39
|
```
|
|
149
40
|
|
|
150
|
-
|
|
41
|
+
If the Gemfile already has `gem "browser"`, replace that line: both gems define the `Browser` class, so Agentry
|
|
42
|
+
refuses to load next to browser. Nothing else in your code changes, though some answers do (see
|
|
43
|
+
[Switching from browser](#switching-from-browser)).
|
|
151
44
|
|
|
152
|
-
|
|
153
|
-
`browser.mobile?`), require the `browser/aliases` file and extend the
|
|
154
|
-
Browser::Base object like the following:
|
|
45
|
+
## A first example
|
|
155
46
|
|
|
156
47
|
```ruby
|
|
157
|
-
require "browser
|
|
158
|
-
Browser::Base.include(Browser::Aliases)
|
|
159
|
-
|
|
160
|
-
browser = Browser.new("Some user agent")
|
|
161
|
-
browser.mobile? #=> false
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
### What's being detected?
|
|
165
|
-
|
|
166
|
-
- For a list of platform detections, check
|
|
167
|
-
[lib/browser/platform.rb](lib/browser/platform.rb)
|
|
168
|
-
- For a list of device detections, check
|
|
169
|
-
[lib/browser/device.rb](lib/browser/device.rb)
|
|
170
|
-
- For a list of bot detections, check
|
|
171
|
-
[bots.yml](bots.yml)
|
|
172
|
-
|
|
173
|
-
### Detecting modern browsers
|
|
174
|
-
|
|
175
|
-
To detect whether a browser can be considered as modern or not, create a method
|
|
176
|
-
that abstracts your versioning constraints. The following example will consider
|
|
177
|
-
any of the following browsers as a modern:
|
|
48
|
+
require "browser"
|
|
178
49
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
browser.facebook?
|
|
191
|
-
&& browser.safari_webapp_mode?
|
|
192
|
-
&& browser.webkit_full_version.to_i >= 602
|
|
193
|
-
].any?
|
|
194
|
-
end
|
|
50
|
+
user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 " \
|
|
51
|
+
"(KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1"
|
|
52
|
+
browser = Browser.new(user_agent, accept_language: "fr-FR,fr;q=0.9,en;q=0.8")
|
|
53
|
+
|
|
54
|
+
browser.name # => "Safari"
|
|
55
|
+
browser.version # => "18"
|
|
56
|
+
browser.full_version # => "18.1"
|
|
57
|
+
browser.platform.name # => "iOS (iPhone)"
|
|
58
|
+
browser.device.mobile? # => true
|
|
59
|
+
browser.bot? # => false
|
|
60
|
+
browser.accept_language.first.full # => "fr-FR"
|
|
195
61
|
```
|
|
196
62
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
Replace `gem "browser"` with `gem "agentry"` in the Gemfile, nothing else: `require "browser"`, the `Browser`
|
|
200
|
-
namespace and every predicate stay. Both gems define `Browser`, so Agentry refuses to load next to `browser`,
|
|
201
|
-
with an error saying so, rather than letting one silently override the other. Agentry requires Ruby 3.3 or later
|
|
202
|
-
and is tested on Rails 8.0 and 8.1.
|
|
203
|
-
|
|
204
|
-
What changes on its own: the bots of the weekly sync are bots from the day you switch (600 user agents of the
|
|
205
|
-
corpus that browser took for humans), so analytics that leave bots out count fewer visitors from that day; 792
|
|
206
|
-
user agents named `Generic Bot` get a name; no browser, platform or device answer moves, and test drivers stay out
|
|
207
|
-
of `bot?`. To roll back, put `gem "browser"` back, after removing the calls to what browser lacks
|
|
208
|
-
(`Browser.cache_size=`, `bot.category`, `bot.ai?`, `bot.user_triggered?`). The
|
|
209
|
-
[migration guide](https://levups.github.io/agentry/guide.html#migrating) has the details.
|
|
210
|
-
|
|
211
|
-
### Rails integration
|
|
63
|
+
The file to require is `browser` and the class is `Browser`, the names browser used. `accept_language:` is optional:
|
|
64
|
+
it takes the `Accept-Language` header, the languages the visitor's browser asks for.
|
|
212
65
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
```ruby
|
|
216
|
-
gem "agentry"
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
This adds a helper method called `browser`, that inspects your current user
|
|
220
|
-
agent.
|
|
66
|
+
In Rails there is nothing to build. The `browser` helper reads the current request's headers:
|
|
221
67
|
|
|
222
68
|
```erb
|
|
223
|
-
<% if browser.
|
|
224
|
-
|
|
69
|
+
<% if browser.device.mobile? %>
|
|
70
|
+
<%= render "mobile_menu" %>
|
|
225
71
|
<% end %>
|
|
226
72
|
```
|
|
227
73
|
|
|
228
|
-
|
|
229
|
-
|
|
74
|
+
Outside Rails, pass the Rack env as `headers:` so that Agentry can read Client Hints and Cloudflare headers too:
|
|
75
|
+
`Browser.new(user_agent, accept_language: language, headers: env)`.
|
|
230
76
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
77
|
+
The [guide](https://levups.github.io/agentry/guide.html#usage) lists every method: browsers from Chrome and Safari
|
|
78
|
+
to WeChat and Yandex, devices such as the iPad, Kindle, PlayStation and Xbox, and platforms down to each version of
|
|
79
|
+
Windows. Version checks take a number or constraints, such as `browser.chrome?(">= 65")` or
|
|
80
|
+
`browser.platform.ios?(9)`.
|
|
234
81
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
Parses the accept-language header from an HTTP request and produces an array of
|
|
238
|
-
language objects sorted by quality.
|
|
82
|
+
## Bots
|
|
239
83
|
|
|
240
84
|
```ruby
|
|
241
|
-
browser = Browser.new("
|
|
242
|
-
|
|
243
|
-
browser.accept_language.class
|
|
244
|
-
#=> Array
|
|
85
|
+
browser = Browser.new("Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)")
|
|
245
86
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
language.region
|
|
252
|
-
#=> "US"
|
|
253
|
-
|
|
254
|
-
language.full
|
|
255
|
-
#=> "en-US"
|
|
256
|
-
|
|
257
|
-
language.quality
|
|
258
|
-
#=> 1.0
|
|
259
|
-
|
|
260
|
-
language.name
|
|
261
|
-
#=> "English/United States"
|
|
87
|
+
browser.bot? # => true
|
|
88
|
+
browser.bot.name # => "ChatGPT"
|
|
89
|
+
browser.bot.category # => :ai_crawler
|
|
90
|
+
browser.bot.ai? # => true
|
|
91
|
+
browser.bot.user_triggered? # => false
|
|
262
92
|
```
|
|
263
93
|
|
|
264
|
-
|
|
265
|
-
|
|
94
|
+
`browser.bot?` is true for an empty user agent, for one that matches `/crawl|fetch|search|monitoring|spider|bot/`,
|
|
95
|
+
and for one that contains a name from [bots.yml](bots.yml). The net is wide on purpose, as it was in browser.
|
|
266
96
|
|
|
267
|
-
|
|
268
|
-
-
|
|
97
|
+
Every Monday, a workflow adds the bots of [crawler-user-agents](https://github.com/monperrus/crawler-user-agents)
|
|
98
|
+
and [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (both MIT) to `bots.yml` and files each under a
|
|
99
|
+
category. It opens a pull request that lists every answer the change moves, and a maintainer merges it. The
|
|
100
|
+
[Bots page](https://levups.github.io/agentry/bots.html#coverage) counts, per category, how many of those bots
|
|
101
|
+
browser and Agentry detect.
|
|
269
102
|
|
|
270
|
-
###
|
|
103
|
+
### Categories
|
|
271
104
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
`
|
|
105
|
+
`browser.bot.category` is what the bot says it is, named after Cloudflare's verified bot categories where one fits:
|
|
106
|
+
`search_engine_crawler`, `ai_crawler`, `ai_search`, `ai_assistant`, `page_preview`, `feed_fetcher`,
|
|
107
|
+
`monitoring_and_analytics`, `advertising_and_marketing`, `archiver`, `security` and a few more. Agentry adds
|
|
108
|
+
`ai_agent`, `http_library`, `scanner` and `browser_automation`. `bot.ai?` is true for the four AI categories, and
|
|
109
|
+
`bot.user_triggered?` for `ai_assistant` and `ai_agent`: a person is driving them at that moment, as with
|
|
110
|
+
ChatGPT-User or Claude-User. The category is `nil` for a browser, for a bot exception, and for a bot no list names.
|
|
277
111
|
|
|
278
|
-
|
|
279
|
-
|
|
112
|
+
HTTP libraries (`curl`, `python-requests`) and scanners (sqlmap, Nikto) are bots. Test drivers (headless Chrome,
|
|
113
|
+
Playwright, Puppeteer, Selenium) are not: your own system tests drive a real browser through them, and would
|
|
114
|
+
otherwise count as bots. They get the `browser_automation` category, so production code can still catch them with
|
|
115
|
+
`browser.bot? || browser.bot.category == :browser_automation`. PhantomJS stays a bot, as in browser.
|
|
280
116
|
|
|
281
|
-
|
|
282
|
-
browser.version
|
|
283
|
-
#=> 11
|
|
284
|
-
|
|
285
|
-
browser.full_version
|
|
286
|
-
#=> 11.0
|
|
117
|
+
### Your own rules
|
|
287
118
|
|
|
288
|
-
browser
|
|
289
|
-
|
|
119
|
+
A matcher is anything that responds to `call(ua, browser)`. This one makes every user agent containing
|
|
120
|
+
`externalhit` a bot:
|
|
290
121
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
browser.compatibility_view?
|
|
295
|
-
#=> true
|
|
122
|
+
```ruby
|
|
123
|
+
Browser::Bot.matchers << ->(ua, _browser) { ua.match?(/externalhit/i) }
|
|
296
124
|
```
|
|
297
125
|
|
|
298
|
-
|
|
299
|
-
|
|
126
|
+
`browser.bot.why?` returns the matcher that fired. The lists can change at runtime too
|
|
127
|
+
(`Browser::Bot.bots.merge!(new_bots_hash)`), and the
|
|
128
|
+
[Bots page](https://levups.github.io/agentry/bots.html#matchers) shows how to remove the built-in matchers or go
|
|
129
|
+
back to the detection of browser 2.x.
|
|
300
130
|
|
|
301
|
-
###
|
|
131
|
+
### Verified crawlers
|
|
302
132
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
133
|
+
Anyone can send `Googlebot` as their user agent. Google, Bing, OpenAI, Apple, Perplexity and DuckDuckGo publish the
|
|
134
|
+
IP ranges their crawlers use, and Agentry ships them, refreshed by the weekly sync. `bot.verified?(ip)` says whether
|
|
135
|
+
a request comes from the ranges of the company its user agent names:
|
|
306
136
|
|
|
307
137
|
```ruby
|
|
308
|
-
#
|
|
309
|
-
browser
|
|
310
|
-
|
|
311
|
-
browser.safari?
|
|
312
|
-
#=> false
|
|
138
|
+
browser.bot.owner # => :google for a user agent claiming to be Googlebot
|
|
139
|
+
browser.bot.verified?(request.remote_ip) # => true when the IP is in Google's ranges
|
|
313
140
|
|
|
314
|
-
browser.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
browser.platform.ios?
|
|
318
|
-
#=> true
|
|
141
|
+
if browser.bot.owner && !browser.bot.verified?(request.remote_ip)
|
|
142
|
+
head :forbidden # claims to be a known crawler, comes from somewhere else
|
|
143
|
+
end
|
|
319
144
|
```
|
|
320
145
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
The bot detection is quite aggressive. Anything that matches at least one of the
|
|
324
|
-
following requirements will be considered a bot.
|
|
325
|
-
|
|
326
|
-
- Empty user agent string
|
|
327
|
-
- User agent that matches `/crawl|fetch|search|monitoring|spider|bot/`
|
|
328
|
-
- Any known bot listed under
|
|
329
|
-
[bots.yml](bots.yml)
|
|
330
|
-
|
|
331
|
-
Every week, `script/sync_bots.rb` adds the bots of
|
|
332
|
-
[crawler-user-agents](https://github.com/monperrus/crawler-user-agents) and
|
|
333
|
-
[ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (both MIT) to the end of `bots.yml`, and
|
|
334
|
-
categorizes them in `bot_categories.yml`, through a pull request that lists the answers that change. How many of
|
|
335
|
-
those lists' bots browser and Agentry detect, per category, is on the
|
|
336
|
-
[Bots page](https://levups.github.io/agentry/bots.html#coverage).
|
|
337
|
-
|
|
338
|
-
`browser.bot.category` names what the bot declares it is, with the names of Cloudflare's verified bot categories
|
|
339
|
-
where one fits (`search_engine_crawler`, `ai_crawler`, `ai_search`, `ai_assistant`, `page_preview`,
|
|
340
|
-
`feed_fetcher`, `monitoring_and_analytics`, `advertising_and_marketing`, `archiver`, `security`…), plus
|
|
341
|
-
`ai_agent`, `http_library`, `scanner` and `browser_automation`. `bot.ai?` is true for the four AI categories,
|
|
342
|
-
`bot.user_triggered?` for `ai_assistant` and `ai_agent`. The category is `nil` for a browser, a bot exception
|
|
343
|
-
and a bot no list names.
|
|
344
|
-
|
|
345
|
-
HTTP libraries (`curl`, `got`, `python-requests`) and scanners (sqlmap, Nikto, Censys) are bots, as `curl` and
|
|
346
|
-
`python-requests` already were in browser. Test drivers (headless Chrome, Playwright, Puppeteer, Selenium) are
|
|
347
|
-
not: an app's system tests drive a real browser through them, and the headless Chrome of Playwright and Selenium
|
|
348
|
-
sends `HeadlessChrome`. They get the `browser_automation` category instead, so an app can tell them apart in
|
|
349
|
-
production (`browser.bot? || browser.bot.category == :browser_automation`). PhantomJS stays a bot, as in
|
|
350
|
-
browser.
|
|
351
|
-
|
|
352
|
-
To add custom matchers, you can add a callable object to
|
|
353
|
-
`Browser::Bot.matchers`. The following example matches everything that has a
|
|
354
|
-
`externalhit` substring on it. The bot name will always be `General Bot`.
|
|
146
|
+
The check searches the ranges in memory, with no network call, and an IP that does not parse answers `false`. Pass
|
|
147
|
+
the IP as your proxies resolve it: `request.remote_ip` in Rails, with `trusted_proxies` set.
|
|
355
148
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
To clear all matchers, including the ones that are bundled, use
|
|
361
|
-
`Browser::Bot.matchers.clear`. You can re-add built-in matchers by doing the
|
|
362
|
-
following:
|
|
149
|
+
`verified?(ip, reverse_dns: true)` adds the DNS check Google, Bing and Apple document, at the cost of two DNS
|
|
150
|
+
queries for an IP outside the ranges. A fake Googlebot controls its own reverse DNS and can make that lookup wait
|
|
151
|
+
for the timeout (1 second per nameserver of `/etc/resolv.conf`), so a cache, off by default, can remember each
|
|
152
|
+
answer, failures included:
|
|
363
153
|
|
|
364
154
|
```ruby
|
|
365
|
-
Browser::Bot.
|
|
155
|
+
Browser::Bot::IPRanges.dns_cache_size = 10_000 # 0, the default, caches nothing
|
|
156
|
+
Browser::Bot::IPRanges.dns_cache_ttl = 3600 # seconds
|
|
157
|
+
Browser::Bot::IPRanges.dns_timeout = 0.5 # seconds per query and nameserver
|
|
158
|
+
Browser::Bot::IPRanges.refresh # fetches the ranges again, in a long-lived process
|
|
366
159
|
```
|
|
367
160
|
|
|
368
|
-
|
|
161
|
+
## Cloudflare and Client Hints
|
|
369
162
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
Browser::Bot.matchers.delete(Browser::Bot::EmptyUserAgentMatcher)
|
|
373
|
-
```
|
|
163
|
+
When the Rack env is passed as `headers:` (the Rails helper and the middleware do it for you), Agentry reads two
|
|
164
|
+
more sources.
|
|
374
165
|
|
|
375
|
-
|
|
166
|
+
Behind Cloudflare, a Transform Rule can copy what Cloudflare knows about a request into headers, and
|
|
167
|
+
`browser.cloudflare` reads them:
|
|
376
168
|
|
|
377
169
|
```ruby
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
170
|
+
browser.cloudflare.verified_bot? # => true for a bot Cloudflare verified
|
|
171
|
+
browser.cloudflare.bot_category # => :search_engine_crawler, with the names of bot.category
|
|
172
|
+
browser.cloudflare.bot_score # => 1 (automated) to 99 (human), with Bot Management only
|
|
173
|
+
browser.cloudflare.country # => "FR", from CF-IPCountry
|
|
382
174
|
```
|
|
383
175
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
network call; an IP that does not parse answers `false`.
|
|
176
|
+
The header names can be changed with `Browser::Cloudflare.header_names = {bot_score: "X-My-Score"}`. These headers
|
|
177
|
+
prove something only when every request goes through Cloudflare: if clients can reach your server directly, they
|
|
178
|
+
can send the headers themselves. The [guide](https://levups.github.io/agentry/guide.html#cloudflare) has the rule to
|
|
179
|
+
create and how to lock the origin.
|
|
389
180
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
181
|
+
Chromium browsers no longer put the real Windows, macOS or Android version in their user agent. They send it in
|
|
182
|
+
Client Hints (`Sec-CH-UA`, `Sec-CH-UA-Mobile`, `Sec-CH-UA-Platform`, `Sec-CH-UA-Platform-Version`), which Agentry
|
|
183
|
+
uses to refine the platform and the device: `browser.platform.windows11?` needs them. Without `headers:`, every
|
|
184
|
+
answer comes from the user agent alone, as in browser. The
|
|
185
|
+
[guide](https://levups.github.io/agentry/guide.html#client-hints) shows how to ask browsers for the platform
|
|
186
|
+
version. `bench/signals.rb` measures what reading these headers costs.
|
|
396
187
|
|
|
397
|
-
|
|
188
|
+
## Accept-Language
|
|
398
189
|
|
|
399
|
-
`
|
|
400
|
-
and the middleware pass them. `browser.cloudflare` reads the headers a Cloudflare Transform Rule sets
|
|
401
|
-
(`verified_bot?`, `bot_category`, `bot_score`) and `CF-IPCountry` (`country`); their names are configurable with
|
|
402
|
-
`Browser::Cloudflare.header_names = {bot_score: "X-My-Score"}`. Chromium's Client Hints (`Sec-CH-UA`,
|
|
403
|
-
`Sec-CH-UA-Mobile`, `Sec-CH-UA-Platform`, `Sec-CH-UA-Platform-Version`) refine the platform and the device, and
|
|
404
|
-
`browser.platform.windows11?` needs them. Without `headers:`, every answer is the user agent's, as before. The
|
|
405
|
-
rule to create and the details are in the [guide](https://levups.github.io/agentry/guide.html#cloudflare).
|
|
406
|
-
|
|
407
|
-
`bench/signals.rb` measures what they cost.
|
|
408
|
-
|
|
409
|
-
### Middleware
|
|
410
|
-
|
|
411
|
-
You can use the `Browser::Middleware` to redirect user agents.
|
|
190
|
+
`browser.accept_language` turns the `Accept-Language` header into a list of languages, the most wanted first:
|
|
412
191
|
|
|
413
192
|
```ruby
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
193
|
+
language = Browser.new(user_agent, accept_language: "en-us").accept_language.first
|
|
194
|
+
|
|
195
|
+
language.code # => "en"
|
|
196
|
+
language.region # => "US"
|
|
197
|
+
language.full # => "en-US"
|
|
198
|
+
language.quality # => 1.0
|
|
199
|
+
language.name # => "English/United States"
|
|
417
200
|
```
|
|
418
201
|
|
|
419
|
-
|
|
420
|
-
|
|
202
|
+
As the HTTP specification says, a language without a quality value has 1.0, and one with a quality of 0 is not
|
|
203
|
+
accepted, so it is left out.
|
|
421
204
|
|
|
422
|
-
|
|
423
|
-
Rails.configuration.middleware.use Browser::Middleware do
|
|
424
|
-
redirect_to upgrade_path if browser.ie?
|
|
425
|
-
end
|
|
426
|
-
```
|
|
205
|
+
## Middleware
|
|
427
206
|
|
|
428
|
-
|
|
429
|
-
|
|
207
|
+
`Browser::Middleware` redirects requests based on the browser, before they reach your app. In Rails, from an
|
|
208
|
+
initializer such as `config/initializers/browser.rb`, route helpers work and `request` is the `Rack::Request`:
|
|
430
209
|
|
|
431
210
|
```ruby
|
|
432
211
|
Rails.configuration.middleware.use Browser::Middleware do
|
|
@@ -434,57 +213,70 @@ Rails.configuration.middleware.use Browser::Middleware do
|
|
|
434
213
|
end
|
|
435
214
|
```
|
|
436
215
|
|
|
437
|
-
|
|
216
|
+
In another Rack app, `use Browser::Middleware do … end` takes the same block. To use Agentry in Rails without the
|
|
217
|
+
`browser` helper, require only the library: `gem "agentry", require: "browser/browser"`.
|
|
438
218
|
|
|
439
|
-
|
|
440
|
-
`Browser.user_agent_size_limit=(size)`.
|
|
441
|
-
- Accept-Language has a size limit of 2048 bytes. This can be customized through
|
|
442
|
-
`Browser.accept_language_size_limit=(size)`.
|
|
219
|
+
## Cache, limits and boot
|
|
443
220
|
|
|
444
|
-
|
|
221
|
+
Each process remembers what it found for the last 1,000 user agents (browser, platform, device and bot answers)
|
|
222
|
+
and the last 1,000 `Accept-Language` headers, about 1 KB per user agent. A user agent seen before costs about 2 µs.
|
|
223
|
+
The cache empties itself when it is full, and when `Browser.matchers`, `Browser::Bot.matchers`,
|
|
224
|
+
`Browser::Platform.matchers`, `Browser::Device.matchers`, the bot list or its exceptions change. Setting
|
|
225
|
+
`Browser.cache_size` empties it too, for example after renaming an entry of `Browser::Bot.bots` in place:
|
|
445
226
|
|
|
446
227
|
```ruby
|
|
447
|
-
Browser.
|
|
448
|
-
Browser.accept_language_size_limit = 4096
|
|
228
|
+
Browser.cache_size = 5_000 # 0 turns it off
|
|
449
229
|
```
|
|
450
230
|
|
|
451
|
-
|
|
231
|
+
A user agent or an `Accept-Language` header longer than 2048 bytes raises `Browser::Error`. Both limits can be
|
|
232
|
+
raised with `Browser.user_agent_size_limit = 4096` and `Browser.accept_language_size_limit = 4096`.
|
|
452
233
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
`Browser.cache_size` empties it too, for example after renaming an entry of `Browser::Bot.bots` in place.
|
|
234
|
+
The lists and matchers are built on first use: the first request of each process takes about 13 ms more, the
|
|
235
|
+
following ones 0.2 ms. `Browser.eager_load!` builds them at boot, so that a server that loads the app before
|
|
236
|
+
forking (Puma's `preload_app!`, Unicorn, Pitchfork) shares them with every worker. Rails calls it whenever it eager
|
|
237
|
+
loads, which production does by default. Elsewhere, call `Browser.eager_load!` before the fork.
|
|
458
238
|
|
|
459
|
-
|
|
460
|
-
Browser.cache_size = 5_000 # 0 turns it off
|
|
461
|
-
```
|
|
239
|
+
## Switching from browser
|
|
462
240
|
|
|
463
|
-
|
|
241
|
+
Replace `gem "browser"` with `gem "agentry"` in the Gemfile. Nothing else in the code changes.
|
|
464
242
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
243
|
+
Some answers change from the day you switch, because of the fresher bot list: 600 user agents of the corpus that
|
|
244
|
+
browser took for people are bots in Agentry, so analytics that leave bots out will count fewer visitors, and 792
|
|
245
|
+
user agents that browser called `Generic Bot` get a real name. No browser, platform or device answer changes, and
|
|
246
|
+
test drivers still are not bots.
|
|
469
247
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
248
|
+
To go back, remove the calls to what browser does not have (`Browser.cache_size=`, the `headers:` argument,
|
|
249
|
+
`bot.category`, `bot.ai?`, `bot.user_triggered?`, `bot.verified?`, `browser.cloudflare`), then put
|
|
250
|
+
`gem "browser"` back. The [migration guide](https://levups.github.io/agentry/guide.html#migrating) has the details.
|
|
473
251
|
|
|
474
252
|
## Performance
|
|
475
253
|
|
|
476
|
-
`bench/request.rb`
|
|
477
|
-
2,000 distinct user agents of the corpus and over 20 repeated ones, median of 7
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
254
|
+
`bench/request.rb` asks what a web request typically asks (name, version, `bot?`, platform, device,
|
|
255
|
+
accept-language) over 2,000 distinct user agents of the corpus and over 20 repeated ones, and keeps the median of 7
|
|
256
|
+
runs. On Ruby 4.0.9 with YJIT:
|
|
257
|
+
|
|
258
|
+
| Per request | browser 6.2.0 | Agentry |
|
|
259
|
+
|---|---:|---:|
|
|
260
|
+
| A new user agent | 126 µs, 245 objects | 25 µs, 52 objects |
|
|
261
|
+
| A user agent seen before | 114 µs, 217 objects | 2 µs, 12 objects |
|
|
262
|
+
|
|
263
|
+
The [benchmarks page](https://levups.github.io/agentry/benchmarks.html) has the runs without YJIT, the machine, and
|
|
264
|
+
why Agentry is faster. To run them again:
|
|
481
265
|
|
|
482
266
|
```bash
|
|
483
|
-
bundle exec rake bench UPSTREAM=path/to/upstream # fnando/browser at dd88933
|
|
267
|
+
bundle exec rake bench UPSTREAM=path/to/upstream # a clone of fnando/browser at dd88933
|
|
484
268
|
```
|
|
485
269
|
|
|
486
|
-
It runs `ruby [--yjit] -I lib bench/request.rb --write` for each gem and JIT,
|
|
487
|
-
Ruby, YJIT, machine, date) in `docs/benchmarks.json`,
|
|
270
|
+
It runs `ruby [--yjit] -I lib bench/request.rb --write` for each gem and JIT setting, records each run (gem,
|
|
271
|
+
commit, Ruby, YJIT, machine, date) in `docs/benchmarks.json`, and `rake docs:render` then rewrites the site's
|
|
272
|
+
numbers from that file.
|
|
273
|
+
|
|
274
|
+
## How the answers are checked
|
|
275
|
+
|
|
276
|
+
Agentry runs browser's own test suite, unmodified, next to its own tests. The repository also holds a corpus of
|
|
277
|
+
20,328 user agents, gathered from uap-core, crawler-user-agents and browser's test fixtures. On every pull request,
|
|
278
|
+
CI asks both browser and Agentry about each of them and compares the answers. An answer that differs is either
|
|
279
|
+
listed in `corpus/expected_differences.jsonl` as an intended change, such as a newly known bot, or fails the build.
|
|
488
280
|
|
|
489
281
|
## Development
|
|
490
282
|
|
|
@@ -494,8 +286,7 @@ bundle exec rake test
|
|
|
494
286
|
bundle exec rubocop
|
|
495
287
|
```
|
|
496
288
|
|
|
497
|
-
|
|
498
|
-
clone of [fnando/browser](https://github.com/fnando/browser) at `dd88933`, as CI does on every pull request:
|
|
289
|
+
To compare every answer with [fnando/browser](https://github.com/fnando/browser) at `dd88933`, as CI does:
|
|
499
290
|
|
|
500
291
|
```bash
|
|
501
292
|
ruby -I path/to/upstream/lib script/corpus_snapshot.rb > /tmp/upstream.jsonl
|
|
@@ -503,10 +294,10 @@ ruby -I lib script/corpus_snapshot.rb > /tmp/agentry.jsonl
|
|
|
503
294
|
ruby script/corpus_diff.rb /tmp/upstream.jsonl /tmp/agentry.jsonl
|
|
504
295
|
```
|
|
505
296
|
|
|
506
|
-
|
|
507
|
-
|
|
297
|
+
It passes when the answers that differ are exactly those in `corpus/expected_differences.jsonl`. A change meant to
|
|
298
|
+
change answers rewrites that file with `ruby script/corpus_diff.rb --write …`, and its diff shows what changed.
|
|
508
299
|
|
|
509
|
-
The weekly bot sync (`.github/workflows/bot-sync.yml`) runs
|
|
300
|
+
The weekly bot sync (`.github/workflows/bot-sync.yml`) runs these steps, which also work by hand:
|
|
510
301
|
|
|
511
302
|
```bash
|
|
512
303
|
ruby script/sync_bots.rb # bots.yml and bot_categories.yml
|
|
@@ -516,37 +307,39 @@ ruby script/bot_coverage.rb path/to/upstream/lib # docs/bot_coverage.json, and i
|
|
|
516
307
|
ruby script/render_docs.rb # the site's numbers, from docs/*.json and the lists
|
|
517
308
|
```
|
|
518
309
|
|
|
519
|
-
The numbers
|
|
520
|
-
`docs/bot_coverage.json`, the bot lists and `corpus/expected_differences.jsonl
|
|
521
|
-
what `bundle exec rake docs:render` writes.
|
|
310
|
+
The numbers on the site's pages (`docs/*.html`) sit between `<!-- render:… -->` markers and come from
|
|
311
|
+
`docs/benchmarks.json`, `docs/bot_coverage.json`, the bot lists and `corpus/expected_differences.jsonl`. CI fails
|
|
312
|
+
when a page differs from what `bundle exec rake docs:render` writes. A new code block on a page needs
|
|
313
|
+
`bundle exec rake docs:highlight`.
|
|
522
314
|
|
|
523
|
-
To add a bot, add its user agent to `test/ua_bots.yml
|
|
524
|
-
|
|
315
|
+
To add a bot, add its user agent to `test/ua_bots.yml`, and its name to `bots.yml` above the synced section, under
|
|
316
|
+
a lowercase string its user agent contains. A search engine goes in `test/ua_search_engines.yml`,
|
|
525
317
|
`test/ua_bots.yml` and `search_engines.yml`.
|
|
526
318
|
|
|
319
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
|
|
320
|
+
|
|
527
321
|
### Releasing
|
|
528
322
|
|
|
529
|
-
Pull requests leave `CHANGELOG.md` alone
|
|
530
|
-
pull requests merged since the last tag, without committing them. Then, from an up-to-date `main`
|
|
531
|
-
green:
|
|
323
|
+
Pull requests leave `CHANGELOG.md` alone. A release starts by writing its notes under `## [Unreleased]`, from the
|
|
324
|
+
pull requests merged since the last tag, without committing them. Then, from an up-to-date `main` with green CI:
|
|
532
325
|
|
|
533
326
|
```sh
|
|
534
|
-
bin/release minor --dry-run # the notes and the changes, nothing
|
|
327
|
+
bin/release minor --dry-run # shows the notes and the changes, writes nothing
|
|
535
328
|
bin/release minor # or patch, major, or an exact version such as 1.2.0
|
|
536
329
|
```
|
|
537
330
|
|
|
538
|
-
`main` takes no direct push, so `bin/release` dates the
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
the
|
|
542
|
-
with the
|
|
331
|
+
`main` takes no direct push, so `bin/release` dates the notes and bumps the version on a `release/vX.Y.Z` branch,
|
|
332
|
+
opens a pull request that merges itself once CI is green, and waits for it. It then pushes a signed `vX.Y.Z` tag on
|
|
333
|
+
the merge commit (`bin/release --tag X.Y.Z` does it if you stopped waiting). The tag starts the Release workflow,
|
|
334
|
+
which checks that the tag is signed and on `main`, then publishes the gem to RubyGems through trusted publishing
|
|
335
|
+
and creates the GitHub release with the notes and the `.gem`.
|
|
543
336
|
|
|
544
337
|
## License
|
|
545
338
|
|
|
546
339
|
MIT, by Level UP Solutions, see [LICENSE.md](LICENSE.md). Originally written by Nando Vieira as
|
|
547
340
|
[fnando/browser](https://github.com/fnando/browser) (MIT). The corpus gathers user agents from
|
|
548
341
|
[ua-parser/uap-core](https://github.com/ua-parser/uap-core) (Apache 2.0),
|
|
549
|
-
[crawler-user-agents](https://github.com/monperrus/crawler-user-agents) (MIT) and browser's test fixtures
|
|
342
|
+
[crawler-user-agents](https://github.com/monperrus/crawler-user-agents) (MIT) and browser's test fixtures. The bot
|
|
550
343
|
list and its categories come from [crawler-user-agents](https://github.com/monperrus/crawler-user-agents) (MIT)
|
|
551
|
-
and [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (MIT)
|
|
552
|
-
owners publish for that purpose
|
|
344
|
+
and [ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) (MIT). The crawler IP ranges are the ones
|
|
345
|
+
their owners publish for that purpose. [corpus/SOURCES.md](corpus/SOURCES.md) lists every source.
|
|
@@ -15,8 +15,10 @@ module Browser
|
|
|
15
15
|
google-read-aloud google-safety google-site-verification googleagent-urlcontext googlebot
|
|
16
16
|
googleother googleproducer mediapartners-google storebot-google
|
|
17
17
|
],
|
|
18
|
-
# https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot
|
|
19
|
-
|
|
18
|
+
# https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot. Not
|
|
19
|
+
# googleusercontent.com: a Google Cloud VM's reverse DNS is x.x.x.x.bc.googleusercontent.com, and the
|
|
20
|
+
# user-triggered fetchers it also names are in the published ranges.
|
|
21
|
+
domains: %w[googlebot.com google.com],
|
|
20
22
|
urls: %w[common-crawlers.json special-crawlers.json user-triggered-fetchers.json
|
|
21
23
|
user-triggered-fetchers-google.json user-triggered-agents.json].map { |file| "#{GOOGLE_RANGES_URL}/#{file}" }
|
|
22
24
|
},
|
|
@@ -50,7 +52,15 @@ module Browser
|
|
|
50
52
|
FETCH_TIMEOUT_SECONDS = 10
|
|
51
53
|
# Owners move their files: Google's old URLs redirect to the new ones.
|
|
52
54
|
MAX_REDIRECTS = 3
|
|
53
|
-
|
|
55
|
+
# How long each DNS query waits for each nameserver of /etc/resolv.conf: a resolver answers in milliseconds,
|
|
56
|
+
# and only a stalled one (or an attacker's own reverse zone) ever waits that long.
|
|
57
|
+
DNS_TIMEOUT_SECONDS = 1
|
|
58
|
+
# The broadest prefix a source may publish: a wider one, or one outside the public unicast space, is a broken
|
|
59
|
+
# or compromised source, never a crawler's range.
|
|
60
|
+
MIN_PREFIX_LENGTH_BY_FAMILY = {Socket::AF_INET => 12, Socket::AF_INET6 => 24}.freeze
|
|
61
|
+
# Multicast and reserved: 224.0.0.0/3 (with 240.0.0.0/4 and the broadcast address), ff00::/8.
|
|
62
|
+
NON_UNICAST_BY_FAMILY = {Socket::AF_INET => IPAddr.new("224.0.0.0/3"), Socket::AF_INET6 => IPAddr.new("ff00::/8")}
|
|
63
|
+
.freeze
|
|
54
64
|
|
|
55
65
|
# The owner whose crawler the lowercase user agent claims to be.
|
|
56
66
|
def self.owner(ua)
|
|
@@ -71,19 +81,80 @@ module Browser
|
|
|
71
81
|
index >= 0 && number <= ends[index]
|
|
72
82
|
end
|
|
73
83
|
|
|
84
|
+
@dns_mutex = Mutex.new
|
|
85
|
+
@dns_verdicts = {}
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
attr_reader :dns_timeout, :dns_cache_size, :dns_cache_ttl
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.positive?(seconds)
|
|
92
|
+
seconds.is_a?(Numeric) && seconds.positive?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Seconds each DNS query waits for each nameserver.
|
|
96
|
+
def self.dns_timeout=(seconds)
|
|
97
|
+
raise ArgumentError, "dns_timeout must be a positive number, got #{seconds.inspect}" unless positive?(seconds)
|
|
98
|
+
|
|
99
|
+
@dns_timeout = seconds
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# How many reverse DNS verdicts to remember, the oldest dropped first: 0, the default, remembers none. A forged
|
|
103
|
+
# Googlebot then costs its DNS queries once per IP and TTL, not on every request.
|
|
104
|
+
def self.dns_cache_size=(size)
|
|
105
|
+
unless size.is_a?(Integer) && size >= 0
|
|
106
|
+
raise ArgumentError, "dns_cache_size must be an Integer >= 0, got #{size.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@dns_cache_size = size
|
|
110
|
+
clear_dns_cache
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Seconds a verdict holds, a failed lookup's as long as any other's.
|
|
114
|
+
def self.dns_cache_ttl=(seconds)
|
|
115
|
+
raise ArgumentError, "dns_cache_ttl must be a positive number, got #{seconds.inspect}" unless positive?(seconds)
|
|
116
|
+
|
|
117
|
+
@dns_cache_ttl = seconds
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.clear_dns_cache
|
|
121
|
+
@dns_mutex.synchronize { @dns_verdicts.clear }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
self.dns_timeout = DNS_TIMEOUT_SECONDS
|
|
125
|
+
self.dns_cache_size = 0
|
|
126
|
+
self.dns_cache_ttl = 3600
|
|
127
|
+
|
|
74
128
|
# Google's documented method: the reverse lookup names one of the owner's
|
|
75
|
-
# domains, and that name resolves back to the IP. Two DNS queries
|
|
129
|
+
# domains, and that name resolves back to the IP. Two DNS queries, or
|
|
130
|
+
# none when dns_cache_size holds a fresh verdict for the IP.
|
|
76
131
|
def self.verified_by_dns?(owner, ip)
|
|
77
132
|
address = parse(ip)
|
|
78
133
|
domains = SOURCES.dig(owner, :domains)
|
|
79
134
|
return false unless address && domains&.any?
|
|
135
|
+
return resolves_to?(address, domains) if dns_cache_size.zero?
|
|
136
|
+
|
|
137
|
+
key = "#{owner} #{address}"
|
|
138
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
|
+
verdict, expires_at = @dns_mutex.synchronize { @dns_verdicts[key] }
|
|
140
|
+
return verdict if expires_at && now < expires_at
|
|
80
141
|
|
|
142
|
+
verdict = resolves_to?(address, domains)
|
|
143
|
+
@dns_mutex.synchronize do
|
|
144
|
+
@dns_verdicts.delete(key)
|
|
145
|
+
@dns_verdicts.shift while @dns_verdicts.size >= dns_cache_size
|
|
146
|
+
@dns_verdicts[key] = [verdict, now + dns_cache_ttl]
|
|
147
|
+
end
|
|
148
|
+
verdict
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.resolves_to?(address, domains)
|
|
81
152
|
require "resolv"
|
|
82
153
|
Resolv::DNS.open do |dns|
|
|
83
|
-
dns.timeouts =
|
|
154
|
+
dns.timeouts = dns_timeout
|
|
84
155
|
host = dns.getname(address.to_s).to_s.downcase
|
|
85
156
|
domains.any? { |domain| host == domain || host.end_with?(".#{domain}") } &&
|
|
86
|
-
dns.getaddresses(host).any? { |resolved| IPAddr.new(resolved.to_s) == address }
|
|
157
|
+
dns.getaddresses(Resolv::DNS::Name.create("#{host}.")).any? { |resolved| IPAddr.new(resolved.to_s) == address }
|
|
87
158
|
end
|
|
88
159
|
rescue Resolv::ResolvError, Resolv::ResolvTimeout, SystemCallError
|
|
89
160
|
false
|
|
@@ -114,7 +185,10 @@ module Browser
|
|
|
114
185
|
def self.fetch
|
|
115
186
|
SOURCES.to_h do |owner, source|
|
|
116
187
|
addresses = source[:urls].flat_map { |url| fetch_prefixes(url) }.map do |prefix|
|
|
117
|
-
IPAddr.new(prefix)
|
|
188
|
+
address = IPAddr.new(prefix)
|
|
189
|
+
raise Error, "#{owner}: implausible prefix #{prefix.inspect}" unless plausible?(address)
|
|
190
|
+
|
|
191
|
+
address
|
|
118
192
|
rescue ArgumentError
|
|
119
193
|
raise Error, "#{owner}: invalid prefix #{prefix.inspect}"
|
|
120
194
|
end
|
|
@@ -135,20 +209,36 @@ module Browser
|
|
|
135
209
|
end
|
|
136
210
|
break unless response.is_a?(Net::HTTPRedirection)
|
|
137
211
|
|
|
138
|
-
|
|
212
|
+
raise Error, "#{url}: HTTP #{response.code} without a Location" unless response["Location"]
|
|
213
|
+
|
|
214
|
+
location = uri.merge(response["Location"])
|
|
215
|
+
# Only the owner's own host, over HTTPS, publishes its ranges.
|
|
216
|
+
raise Error, "#{url}: redirected to #{location}" unless location.is_a?(URI::HTTPS) && location.host == uri.host
|
|
217
|
+
|
|
218
|
+
uri = location
|
|
139
219
|
end
|
|
140
220
|
raise Error, "#{url}: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
141
221
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
222
|
+
document = JSON.parse(response.body)
|
|
223
|
+
entries = document.fetch("prefixes") if document.is_a?(Hash)
|
|
224
|
+
raise Error, "#{url}: no prefixes" unless entries.is_a?(Array) && entries.any?
|
|
225
|
+
|
|
226
|
+
entries.map do |entry|
|
|
227
|
+
prefix = entry["ipv4Prefix"] || entry["ipv6Prefix"] if entry.is_a?(Hash)
|
|
228
|
+
raise Error, "#{url}: invalid entry #{entry.inspect[0, 100]}" unless prefix.is_a?(String)
|
|
146
229
|
|
|
147
|
-
|
|
148
|
-
|
|
230
|
+
prefix
|
|
231
|
+
end
|
|
232
|
+
rescue JSON::ParserError, KeyError, URI::Error, Timeout::Error, IOError, SystemCallError, OpenSSL::SSL::SSLError => exception
|
|
149
233
|
raise Error, "#{url}: #{exception.message}"
|
|
150
234
|
end
|
|
151
235
|
|
|
236
|
+
def self.plausible?(address)
|
|
237
|
+
address.prefix >= MIN_PREFIX_LENGTH_BY_FAMILY.fetch(address.family) && !address.to_i.zero? &&
|
|
238
|
+
!(address.private? || address.loopback? || address.link_local?) &&
|
|
239
|
+
!NON_UNICAST_BY_FAMILY.fetch(address.family).include?(address)
|
|
240
|
+
end
|
|
241
|
+
|
|
152
242
|
# Per owner and address family, the merged ranges as two sorted arrays of
|
|
153
243
|
# integers, first and last addresses, for a binary search.
|
|
154
244
|
def self.build_ranges(prefixes_by_owner)
|
|
@@ -188,7 +278,7 @@ module Browser
|
|
|
188
278
|
nil
|
|
189
279
|
end
|
|
190
280
|
|
|
191
|
-
private_class_method :fetch_prefixes, :build_ranges, :merge, :parse
|
|
281
|
+
private_class_method :resolves_to?, :positive?, :fetch_prefixes, :plausible?, :build_ranges, :merge, :parse
|
|
192
282
|
end
|
|
193
283
|
end
|
|
194
284
|
end
|
data/lib/browser/cloudflare.rb
CHANGED
|
@@ -5,6 +5,8 @@ module Browser
|
|
|
5
5
|
# sets (docs/guide.html#cloudflare) and its own CF-IPCountry. Each site names
|
|
6
6
|
# its headers in its rule: Browser::Cloudflare.header_names= changes them.
|
|
7
7
|
class Cloudflare
|
|
8
|
+
# Cloudflare's values are a few bytes: a longer one is forged, and ignored.
|
|
9
|
+
HEADER_SIZE_LIMIT_BYTES = 128
|
|
8
10
|
DEFAULT_HEADER_NAMES = {
|
|
9
11
|
verified_bot: "X-Bot-Verified",
|
|
10
12
|
bot_category: "X-Bot-Category",
|
|
@@ -59,7 +61,7 @@ module Browser
|
|
|
59
61
|
|
|
60
62
|
private def header(signal)
|
|
61
63
|
value = @headers&.[](self.class.env_key_by_signal[signal])
|
|
62
|
-
value if value.is_a?(String)
|
|
64
|
+
value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES
|
|
63
65
|
end
|
|
64
66
|
end
|
|
65
67
|
end
|
data/lib/browser/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agentry
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nando Vieira
|
|
@@ -268,7 +268,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
268
268
|
- !ruby/object:Gem::Version
|
|
269
269
|
version: '0'
|
|
270
270
|
requirements: []
|
|
271
|
-
rubygems_version: 4.0.
|
|
271
|
+
rubygems_version: 4.0.20
|
|
272
272
|
specification_version: 4
|
|
273
273
|
summary: 'Browser detection for Ruby: the browser gem''s API, faster'
|
|
274
274
|
test_files: []
|