siteread 0.0.0-stage → 0.2.1
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.
- package/LICENSE +21 -0
- package/README.md +320 -2
- package/bin/siteread.js +460 -0
- package/package.json +35 -4
- package/src/agents.js +82 -0
- package/src/digest/csv.js +37 -0
- package/src/digest/gsc.js +233 -0
- package/src/digest/index.js +81 -0
- package/src/digest/log.js +466 -0
- package/src/digest/referrer.js +77 -0
- package/src/digest/zip.js +41 -0
- package/src/file-store.js +49 -0
- package/src/index.js +17 -0
- package/src/ip-ranges.js +221 -0
- package/src/scanner.js +55 -0
- package/src/share.js +221 -0
- package/src/verifier.js +231 -0
- package/src/version.js +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Synacl Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,321 @@
|
|
|
1
|
-
#
|
|
1
|
+
# siteread
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Was that really ClaudeBot?** `siteread` takes the IP and User-Agent of a request from your access
|
|
4
|
+
log and checks the IP against the list the claimed vendor publishes: `verified`, `spoofed` or `unknown`.
|
|
5
|
+
|
|
6
|
+
Node ≥ 20, zero dependencies, nothing leaves your machine except GETs of the vendors' public IP lists
|
|
7
|
+
(and a `share`, if you choose to send one).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install siteread # the library
|
|
13
|
+
npx siteread --help # the CLI, no install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## CLI
|
|
17
|
+
|
|
18
|
+
`check` answers one request. The first call fetches the crawler lists into a cache file; later calls
|
|
19
|
+
reuse a copy younger than 24 h. The lists come from the siteread feed (one GET instead of nine;
|
|
20
|
+
below); `--no-feed` fetches each from its vendor instead, and any list the feed cannot give comes
|
|
21
|
+
from its vendor anyway.
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
$ npx siteread check 216.73.216.12 "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)"
|
|
25
|
+
verified 216.73.216.12 is claudebot (claude, training): in claude.com/crawling/bots.json (38 prefixes, fetched 2026-10-20T20:04:53Z)
|
|
26
|
+
|
|
27
|
+
$ npx siteread check 203.0.113.9 "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot"
|
|
28
|
+
spoofed 203.0.113.9 claims chatgpt-user (chatgpt, answer) but is not in openai.com/chatgpt-user.json (234 prefixes, fetched 2026-10-20T20:04:53Z)
|
|
29
|
+
|
|
30
|
+
$ npx siteread check 203.0.113.9 "Claude-User (claude-code/2.1.292; +https://support.anthropic.com/)"
|
|
31
|
+
unknown 203.0.113.9 claims claude-code (claude, answer): Claude Code's WebFetch runs from the user's own machine, so no vendor list can cover it. If this IP also requested scanner paths in your log, treat it as spoofed
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Exit | Meaning |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `0` | verified: the IP is in the vendor's list for that agent |
|
|
37
|
+
| `2` | spoofed: the vendor publishes a list and the IP is not in it |
|
|
38
|
+
| `3` | unknown: no list to check against, or the UA is not a known AI agent or search crawler |
|
|
39
|
+
| `1` | usage error, or the IP is not an IP address |
|
|
40
|
+
|
|
41
|
+
`lists` shows what the cache holds:
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
$ npx siteread lists
|
|
45
|
+
agent prefixes fetched (UTC) source
|
|
46
|
+
chatgpt-user 234 2026-10-20T20:04:53Z https://openai.com/chatgpt-user.json
|
|
47
|
+
oai-searchbot 39 2026-10-20T20:04:53Z https://openai.com/searchbot.json
|
|
48
|
+
gptbot 18 2026-10-20T20:04:53Z https://openai.com/gptbot.json
|
|
49
|
+
perplexity-user 4 2026-10-20T20:04:53Z https://www.perplexity.com/perplexity-user.json
|
|
50
|
+
perplexitybot 8 2026-10-20T20:04:53Z https://www.perplexity.com/perplexitybot.json
|
|
51
|
+
applebot 24 2026-10-20T20:04:53Z https://search.developer.apple.com/applebot.json
|
|
52
|
+
claude-user 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
|
|
53
|
+
claude-searchbot 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
|
|
54
|
+
claudebot 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
|
|
55
|
+
googlebot 317 2026-10-20T20:04:53Z https://developers.google.com/static/search/apis/ipranges/googlebot.json
|
|
56
|
+
bingbot 28 2026-10-20T20:04:53Z https://www.bing.com/toolbox/bingbot.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Options: `--no-cache` refetches every list now (a failed fetch still falls back to the cached copy),
|
|
60
|
+
`--json` prints the whole result. The cache is `<os.tmpdir()>/siteread/lists.json`; set
|
|
61
|
+
`SITEREAD_CACHE` to put it elsewhere. `siteread --help` prints all of this.
|
|
62
|
+
|
|
63
|
+
## Digest (for the `seo-read` skill)
|
|
64
|
+
|
|
65
|
+
`digest` reads a Search Console export (the Performance → Export → Download CSV zip) and your
|
|
66
|
+
access logs (nginx/Apache combined, Caddy's JSON, or an nginx JSON `log_format`; `.gz` fine) and prints one JSON document: weekly GSC totals with
|
|
67
|
+
impression-weighted position, people by first-touch source (an IP that sent a browser UA, loaded
|
|
68
|
+
an asset and never probed a scanner path), goal counts, and every crawler checked against its
|
|
69
|
+
vendor's list.
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
$ npx siteread digest --gsc export.zip --log access.log.1.gz --log access.log \
|
|
73
|
+
--site example.org --sitemap sitemap.xml --goal 'signup=^POST /signup 2' --out .siteread/digests
|
|
74
|
+
.siteread/digests/2026-10-18.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A JSON log that holds several sites (one nginx serving an API, an app and a blog) is cut to one
|
|
78
|
+
with `--host`, repeated for each name (`www.` is a different host). Combined lines carry no host,
|
|
79
|
+
so `--host` keeps them and says so. For nginx JSON, the common field names are read
|
|
80
|
+
(`remote_addr`, `request_uri` or `request`, `status`, `time`/`time_iso8601`/`time_local`/`msec`,
|
|
81
|
+
`http_user_agent` or `user_agent`, `http_referer` or `referer`, `host` or `http_host`), with
|
|
82
|
+
`escape=json` or nginx's default escaping.
|
|
83
|
+
|
|
84
|
+
`npx siteread --help` lists every option. The `seo-read` skill for Claude Code, in this project's
|
|
85
|
+
`skill/` folder, turns the JSON into a weekly memo.
|
|
86
|
+
|
|
87
|
+
## Share (opt-in)
|
|
88
|
+
|
|
89
|
+
`share` sends one week of a digest's aggregate numbers to the public benchmark and prints where each
|
|
90
|
+
number stands among the quartiles the benchmark publishes. It prints the exact bytes first and sends nothing without a yes (a
|
|
91
|
+
prompt, or `--yes` in a script). `--dry-run` prints and stops.
|
|
92
|
+
|
|
93
|
+
```console
|
|
94
|
+
$ npx siteread share --dry-run
|
|
95
|
+
siteread: the whole payload from .siteread/digests/2026-10-18.json, byte for byte, for https://api.siteread.dev/v1/share:
|
|
96
|
+
{
|
|
97
|
+
"schema": 1,
|
|
98
|
+
"id": "3803e566762f2e56ba381b993ddf466e",
|
|
99
|
+
"week": "2026-W42",
|
|
100
|
+
"siteread": "0.2.1",
|
|
101
|
+
"metrics": {
|
|
102
|
+
"people": 4,
|
|
103
|
+
"peopleFromSearch": 1,
|
|
104
|
+
"peopleFromAi": 0,
|
|
105
|
+
"peopleFromSocial": 2,
|
|
106
|
+
"aiAnswerFetches": 0,
|
|
107
|
+
"aiSearchFetches": 0,
|
|
108
|
+
"aiTrainingFetches": 2,
|
|
109
|
+
"searchEngineFetches": 8,
|
|
110
|
+
"aiSpoofedPct": 25,
|
|
111
|
+
"gscImpressions": 238,
|
|
112
|
+
"gscClicks": 3,
|
|
113
|
+
"gscCtrPct": 1.3,
|
|
114
|
+
"gscPosition": 17.9
|
|
115
|
+
},
|
|
116
|
+
"proof": "2746561"
|
|
117
|
+
}
|
|
118
|
+
siteread: not sent (--dry-run)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The answer places each number in a band among the published 25th, 50th and 75th percentiles, and
|
|
122
|
+
only for numbers the benchmark page already shows a spread for; for the rest it gives the count of
|
|
123
|
+
sites so far. It is read from the published benchmark (rebuilt hourly), not from other sites' rows,
|
|
124
|
+
so it never tells you more than the page does. With 14 anonymous sites, 9 of them with Search
|
|
125
|
+
Console numbers (made up for this example):
|
|
126
|
+
|
|
127
|
+
```console
|
|
128
|
+
sent 2026-W42. Against the benchmark built 2026-10-21 09:00 UTC: 14 anonymous sites, 2026-W39 to 2026-W42
|
|
129
|
+
|
|
130
|
+
metric yours where
|
|
131
|
+
people 4 below the 25th percentile
|
|
132
|
+
peopleFromAi 0 25th to 50th percentile
|
|
133
|
+
gscImpressions 238 9 sites; shown from 10
|
|
134
|
+
gscPosition 17.9 9 sites; shown from 10
|
|
135
|
+
…
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **What is sent:** the numbers above for the digest's last 7 days (`aiSpoofedPct` is over the whole
|
|
139
|
+
log), a week label, the version, and a random id. `SHARE_METRICS` defines each one.
|
|
140
|
+
- **What is never sent:** hostnames, URLs or paths, visitor IPs, user-agents, query text, goal
|
|
141
|
+
labels. The payload is built from an allowlist of numbers, and the endpoint refuses anything else.
|
|
142
|
+
- **The id** is 128 random bits in `.siteread/share-id`, made on first use. It makes the benchmark
|
|
143
|
+
count sites rather than shares, and a second share of the same week replaces the first. Delete the
|
|
144
|
+
file to start over as a new site.
|
|
145
|
+
- **The proof** is a proof of work tied to the id: a number that makes SHA-256 of the id start with
|
|
146
|
+
22 zero bits, a few seconds of CPU once, kept in `.siteread/share-proof`. Only a site's first
|
|
147
|
+
share needs it. Ids are free to make, so without it one sender could invent a flood of sites; the
|
|
148
|
+
service never reads an address, so it makes each new site cost time instead, and the price rises
|
|
149
|
+
by one bit for every 50 new sites in a day. On a busy day the service asks for more; the CLI makes
|
|
150
|
+
it and tells you to run `share` again.
|
|
151
|
+
- A part with less than a full week (a 4-day log, an export whose last week is cut short) is left
|
|
152
|
+
out and the CLI says so. `--endpoint` (or `SITEREAD_SHARE_URL`) sends elsewhere: `https://` only,
|
|
153
|
+
plain `http://` only to localhost.
|
|
154
|
+
|
|
155
|
+
In the library, `sharePayload(digest, { id })` returns `{ payload, notes }`, and `validateShare(body)`
|
|
156
|
+
is the endpoint's check.
|
|
157
|
+
|
|
158
|
+
## Library
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
import { createVerifier, isScannerPath } from 'siteread';
|
|
162
|
+
|
|
163
|
+
const verifier = createVerifier();
|
|
164
|
+
await verifier.refresh(); // fetch the lists; call again every few hours
|
|
165
|
+
verifier.check('216.73.216.12', 'Mozilla/5.0 … ClaudeBot/1.0; +claudebot@anthropic.com)');
|
|
166
|
+
// {
|
|
167
|
+
// agent: 'claudebot', kind: 'ai', assistant: 'claude', purpose: 'training',
|
|
168
|
+
// source: 'https://claude.com/crawling/bots.json', verdict: 'verified', reason: 'in-vendor-list'
|
|
169
|
+
// }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### `createVerifier(options)`
|
|
173
|
+
|
|
174
|
+
| Option | Default | |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `fetch` | `globalThis.fetch` | any WHATWG fetch |
|
|
177
|
+
| `store` | in memory | `{ get(): Promise<doc \| null>, set(doc): Promise<void> }`; `fileStore(path)` keeps it in a JSON file |
|
|
178
|
+
| `sources` | `SOURCES` | `[{ agent, url }]`, which lists to fetch |
|
|
179
|
+
| `now` | `Date.now` | a clock, for tests |
|
|
180
|
+
| `log` | `console` | `{ warn, error }`; pass `{}` for silence |
|
|
181
|
+
| `isKnownScannerIp` | none | `(ip) => boolean`, the 444 rule (below) |
|
|
182
|
+
| `feedUrl` | none | one merged document instead of the vendor URLs (below); `FEED_URL` is the hosted one |
|
|
183
|
+
| `fallback` | `false` | with `feedUrl`: fetch from the vendors what the feed could not give |
|
|
184
|
+
|
|
185
|
+
It returns:
|
|
186
|
+
|
|
187
|
+
- **`refresh({ force })`** reads the store, fetches every list that is due, drops what is too old and
|
|
188
|
+
writes back. It never throws and returns `{ fetched, failed, fresh, dropped, errors }` (agent keys;
|
|
189
|
+
`errors` maps a failed agent to its reason). `force` refetches lists that are still fresh.
|
|
190
|
+
- **`check(ip, ua)`** is synchronous and uses the lists the last `refresh()` or `load()` saw. It
|
|
191
|
+
returns `{ agent, kind, assistant, purpose, source, verdict, reason }`:
|
|
192
|
+
|
|
193
|
+
| verdict | reason | when |
|
|
194
|
+
|---|---|---|
|
|
195
|
+
| `verified` | `in-vendor-list` | the IP is in the list for the agent the UA claims |
|
|
196
|
+
| `spoofed` | `not-in-vendor-list` | that agent has a list and the IP is not in it |
|
|
197
|
+
| `spoofed` | `robots-token-not-a-user-agent` | the UA says Google-Extended (a robots.txt token, never sent as a UA) |
|
|
198
|
+
| `spoofed` | `known-scanner-ip` | no list, and your `isKnownScannerIp(ip)` said yes |
|
|
199
|
+
| `unknown` | `no-vendor-list` | the operator publishes no list (CCBot, YandexBot, Claude Code, …) |
|
|
200
|
+
| `unknown` | `list-unavailable` | it has one, but it could not be loaded and there is no copy younger than 8 days |
|
|
201
|
+
| `unknown` | `invalid-ip` | the IP did not parse |
|
|
202
|
+
| `n/a` | `not-a-known-agent` | the UA claims to be no known AI agent or search crawler (`agent: null`) |
|
|
203
|
+
|
|
204
|
+
`kind` is `'ai'` (an `AGENTS` entry) or `'crawler'` (a `CRAWLERS` entry). An IPv4-mapped IPv6
|
|
205
|
+
address (`::ffff:1.2.3.4`, as a dual-stack listener logs it) is checked as the IPv4 it is.
|
|
206
|
+
- **`load()`** rebuilds the matcher from the store without fetching: for workers that share a store
|
|
207
|
+
with one process that refreshes.
|
|
208
|
+
- **`lists()`** returns `[{ agent, url, prefixes, fetchedAt, fresh }]` in `sources` order.
|
|
209
|
+
|
|
210
|
+
### The 444 rule, for agents without a list
|
|
211
|
+
|
|
212
|
+
Most operators publish no IP list. For those, `siteread` says `unknown` rather than guess. If you
|
|
213
|
+
know which IPs probed your site for scanner paths, pass that and an `unknown` becomes `spoofed`:
|
|
214
|
+
|
|
215
|
+
```js
|
|
216
|
+
import { readFileSync } from 'node:fs';
|
|
217
|
+
import { createVerifier, fileStore, isScannerPath } from 'siteread';
|
|
218
|
+
|
|
219
|
+
const lines = readFileSync('access.log', 'utf8').trim().split('\n').map(JSON.parse); // your log format here
|
|
220
|
+
const scanners = new Set(lines.filter(l => isScannerPath(l.request_uri)).map(l => l.remote_addr));
|
|
221
|
+
|
|
222
|
+
const verifier = createVerifier({ store: fileStore('.siteread.json'), isKnownScannerIp: ip => scanners.has(ip) });
|
|
223
|
+
await verifier.refresh();
|
|
224
|
+
for (const l of lines) {
|
|
225
|
+
const r = verifier.check(l.remote_addr, l.http_user_agent);
|
|
226
|
+
if (r.agent) console.log(r.verdict, r.agent, l.request_uri);
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The hook only ever turns `unknown` into `spoofed`. When the agent has a vendor list, the list wins,
|
|
231
|
+
even for an IP that hit a scanner path. The ClaudeBot story below is why.
|
|
232
|
+
|
|
233
|
+
### UA tables and scanner paths
|
|
234
|
+
|
|
235
|
+
- **`AGENTS`** lists 23 AI agents as `{ key, re, assistant, purpose }`, where `purpose` is `answer`
|
|
236
|
+
(fetched live for a person), `search` (an assistant's index) or `training`. The FIRST match wins.
|
|
237
|
+
**`detectAgent(ua)`** returns the entry or `null`.
|
|
238
|
+
- **`CRAWLERS`** lists classic search and SEO crawlers (Googlebot, bingbot, YandexBot, DuckDuckBot,
|
|
239
|
+
Slurp, PetalBot, AhrefsBot, SemrushBot, MJ12bot, facebookexternalhit). **`detectCrawler(ua)`**
|
|
240
|
+
returns the entry, and **`isSearchCrawler(ua)`** returns a boolean. AI agents are never search
|
|
241
|
+
crawlers here: Applebot, GPTBot and the rest are in `AGENTS`.
|
|
242
|
+
- **`isScannerPath(path, { allow })`** is true for a vulnerability-scanner request target: dotfiles
|
|
243
|
+
(but not `/.well-known/`), `.php`/`.asp`/`.jsp`/`.env`/`.sql`/`.bak`/`.sh`…, `wp-*`, phpMyAdmin,
|
|
244
|
+
`cgi-bin`, router exploits, percent-encoded dots (`/%2eenv`), secret and config file names,
|
|
245
|
+
`Dockerfile`. Pass the raw request target (path + query, not URL-decoded: nginx's
|
|
246
|
+
`$request_uri`). If a real path of yours matches, exempt it:
|
|
247
|
+
`isScannerPath(p, { allow: /^\/(install\.sh|admin\/config\.yml)(\?|$)/ })`.
|
|
248
|
+
- **`parseCidr`**, **`parseList`**, **`normIp`** and **`SOURCES`** are exported for tests and extensions.
|
|
249
|
+
|
|
250
|
+
## Why
|
|
251
|
+
|
|
252
|
+
This code was built to count AI traffic on one small site's nginx log, and every simple approach
|
|
253
|
+
gave the wrong answer.
|
|
254
|
+
|
|
255
|
+
**Scanners wear AI user-agents.** One scanner cycles through them. In 30 days of the log
|
|
256
|
+
(to 2026-10-16), 217 of the 221 ChatGPT-User requests from outside OpenAI's list came from IPs that
|
|
257
|
+
also drew a 444 (nginx's "close, send nothing" reply to a scanner path). Every request claiming to be
|
|
258
|
+
DeepSeekBot, CCBot, Grok, Perplexity-User or Claude-SearchBot was fake. A UA count is a count of
|
|
259
|
+
what scanners chose to say.
|
|
260
|
+
|
|
261
|
+
**The 444 rule cannot clear a real crawler that follows a bad link.** Before Anthropic's list was
|
|
262
|
+
used, the test was "this IP never hit a scanner path." Then ClaudeBot resolved a GitHub README's
|
|
263
|
+
relative links against the site and asked for `/protocol//blob/master/examples/docker-compose.yml`.
|
|
264
|
+
It drew one 444, and all 50 of that IP's reads that day were counted as spoofed. Anthropic publishes
|
|
265
|
+
one list for its bots (`claude.com/crawling/bots.json`). In those 30 days every ClaudeBot IP and
|
|
266
|
+
every page-reading Claude-User IP was in it, and the four IPs probing `/.env`-style paths under
|
|
267
|
+
Claude names were not. Hence the rule: where a list exists, it decides.
|
|
268
|
+
|
|
269
|
+
**Claude Code is not Anthropic's crawler.** Claude Code's WebFetch sends
|
|
270
|
+
`Claude-User (claude-code/…)` from the user's own machine and IP, so no list can cover it. It is
|
|
271
|
+
matched before `Claude-User` and always comes back `unknown`.
|
|
272
|
+
|
|
273
|
+
**Google-Extended is not a User-Agent.** It is a robots.txt token that Google's crawlers obey while
|
|
274
|
+
fetching as Googlebot. A request that sends it as a UA is not from Google.
|
|
275
|
+
|
|
276
|
+
## Vendor lists
|
|
277
|
+
|
|
278
|
+
| Agent(s) | List |
|
|
279
|
+
|---|---|
|
|
280
|
+
| chatgpt-user | https://openai.com/chatgpt-user.json |
|
|
281
|
+
| oai-searchbot | https://openai.com/searchbot.json |
|
|
282
|
+
| gptbot | https://openai.com/gptbot.json |
|
|
283
|
+
| perplexity-user | https://www.perplexity.com/perplexity-user.json |
|
|
284
|
+
| perplexitybot | https://www.perplexity.com/perplexitybot.json |
|
|
285
|
+
| applebot | https://search.developer.apple.com/applebot.json |
|
|
286
|
+
| claude-user, claude-searchbot, claudebot | https://claude.com/crawling/bots.json (fetched once for all three) |
|
|
287
|
+
| googlebot | https://developers.google.com/static/search/apis/ipranges/googlebot.json |
|
|
288
|
+
| bingbot | https://www.bing.com/toolbox/bingbot.json |
|
|
289
|
+
|
|
290
|
+
All of them answer `{ "creationTime": …, "prefixes": [{ "ipv4Prefix": … } | { "ipv6Prefix": … }] }`.
|
|
291
|
+
|
|
292
|
+
- **Refresh:** a list is refetched only when the stored copy is more than 24 h old.
|
|
293
|
+
- **Failure:** a failed fetch keeps the previous copy, so a vendor's CDN hiccup does not turn its real
|
|
294
|
+
crawlers into `spoofed`. A 200 that is not JSON (an HTML challenge page, a moved list) counts as a
|
|
295
|
+
failure, not as an empty list.
|
|
296
|
+
- **Drop:** a copy nobody could refresh for 8 days is dropped, so a vendor's moved ranges cannot mark
|
|
297
|
+
its new IPs as spoofed forever. With no copy, the verdict is `unknown`, never `spoofed`.
|
|
298
|
+
- **Bounds:** a body over 2,000,000 characters, more than 20,000 prefixes, or a prefix broader than
|
|
299
|
+
/8 (IPv4) or /16 (IPv6) is rejected. A `/0` would verify every address.
|
|
300
|
+
|
|
301
|
+
## Privacy
|
|
302
|
+
|
|
303
|
+
Local use sends nothing anywhere except plain GETs of public crawler lists, with the User-Agent
|
|
304
|
+
`siteread/<version>`: the siteread feed by default, or the vendors' URLs above with `--no-feed` (or
|
|
305
|
+
`SITEREAD_FEED_URL=off`), and those vendors whenever the feed fails. The feed's service keeps no
|
|
306
|
+
address; it counts fetches per day by siteread version. No hostnames, URLs or visitor IPs leave
|
|
307
|
+
your machine. There is no reverse-DNS
|
|
308
|
+
check for the same reason: a PTR lookup sends the visitor's IP to your resolver. `share` is the one
|
|
309
|
+
exception: opt-in, its exact payload printed first, aggregate numbers only.
|
|
310
|
+
|
|
311
|
+
`feedUrl` points the verifier at one merged document,
|
|
312
|
+
`{ "lists": { "<agent>": { "prefixes": [...], "fetchedAt": "..." } } }`, instead of the vendors: the
|
|
313
|
+
hosted feed is `https://api.siteread.dev/v1/lists.json` (`FEED_URL`, this repo's `cloud/`, rebuilt
|
|
314
|
+
from the vendors every 6 h). The CLI uses it by default with `fallback: true`, which fetches from the
|
|
315
|
+
vendors whatever the feed could not give; the library leaves both off unless you pass them. Every
|
|
316
|
+
prefix from a feed is re-validated like a vendor's, and a `fetchedAt` in the future is clamped to
|
|
317
|
+
now.
|
|
318
|
+
|
|
319
|
+
## License
|
|
320
|
+
|
|
321
|
+
MIT.
|