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 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
- # Temporary Holding Version
1
+ # siteread
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.