siteread 0.0.0-stage → 0.2.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.
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,233 @@
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
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm install siteread # the library
12
+ npx siteread --help # the CLI, no install
13
+ ```
14
+
15
+ ## CLI
16
+
17
+ `check` answers one request. The first call fetches the vendor lists into a cache file; later calls
18
+ reuse a copy younger than 24 h.
19
+
20
+ ```console
21
+ $ npx siteread check 216.73.216.12 "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)"
22
+ verified 216.73.216.12 is claudebot (claude, training): in claude.com/crawling/bots.json (38 prefixes, fetched 2026-10-20T20:04:53Z)
23
+
24
+ $ 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"
25
+ 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)
26
+
27
+ $ npx siteread check 203.0.113.9 "Claude-User (claude-code/2.1.292; +https://support.anthropic.com/)"
28
+ 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
29
+ ```
30
+
31
+ | Exit | Meaning |
32
+ |---|---|
33
+ | `0` | verified: the IP is in the vendor's list for that agent |
34
+ | `2` | spoofed: the vendor publishes a list and the IP is not in it |
35
+ | `3` | unknown: no list to check against, or the UA is not a known AI agent or search crawler |
36
+ | `1` | usage error, or the IP is not an IP address |
37
+
38
+ `lists` shows what the cache holds:
39
+
40
+ ```console
41
+ $ npx siteread lists
42
+ agent prefixes fetched (UTC) source
43
+ chatgpt-user 234 2026-10-20T20:04:53Z https://openai.com/chatgpt-user.json
44
+ oai-searchbot 39 2026-10-20T20:04:53Z https://openai.com/searchbot.json
45
+ gptbot 18 2026-10-20T20:04:53Z https://openai.com/gptbot.json
46
+ perplexity-user 4 2026-10-20T20:04:53Z https://www.perplexity.com/perplexity-user.json
47
+ perplexitybot 8 2026-10-20T20:04:53Z https://www.perplexity.com/perplexitybot.json
48
+ applebot 24 2026-10-20T20:04:53Z https://search.developer.apple.com/applebot.json
49
+ claude-user 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
50
+ claude-searchbot 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
51
+ claudebot 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
52
+ googlebot 317 2026-10-20T20:04:53Z https://developers.google.com/static/search/apis/ipranges/googlebot.json
53
+ bingbot 28 2026-10-20T20:04:53Z https://www.bing.com/toolbox/bingbot.json
54
+ ```
55
+
56
+ Options: `--no-cache` refetches every list now (a failed fetch still falls back to the cached copy),
57
+ `--json` prints the whole result. The cache is `<os.tmpdir()>/siteread/lists.json`; set
58
+ `SITEREAD_CACHE` to put it elsewhere. `siteread --help` prints all of this.
59
+
60
+ ## Digest (for the `seo-read` skill)
61
+
62
+ `digest` reads a Search Console export (the Performance → Export → Download CSV zip) and your
63
+ access logs (nginx/Apache combined, `.gz` fine) and prints one JSON document: weekly GSC totals with
64
+ impression-weighted position, people by first-touch source (an IP that sent a browser UA, loaded
65
+ an asset and never probed a scanner path), goal counts, and every crawler checked against its
66
+ vendor's list.
67
+
68
+ ```console
69
+ $ npx siteread digest --gsc export.zip --log access.log.1.gz --log access.log \
70
+ --site example.org --sitemap sitemap.xml --goal 'signup=^POST /signup 2' --out .siteread/digests
71
+ .siteread/digests/2026-10-18.json
72
+ ```
73
+
74
+ `npx siteread --help` lists every option. The skill in [`skill/`](../../skill/README.md) turns
75
+ the JSON into a weekly memo.
76
+
77
+ ## Library
78
+
79
+ ```js
80
+ import { createVerifier, isScannerPath } from 'siteread';
81
+
82
+ const verifier = createVerifier();
83
+ await verifier.refresh(); // fetch the lists; call again every few hours
84
+ verifier.check('216.73.216.12', 'Mozilla/5.0 … ClaudeBot/1.0; +claudebot@anthropic.com)');
85
+ // {
86
+ // agent: 'claudebot', kind: 'ai', assistant: 'claude', purpose: 'training',
87
+ // source: 'https://claude.com/crawling/bots.json', verdict: 'verified', reason: 'in-vendor-list'
88
+ // }
89
+ ```
90
+
91
+ ### `createVerifier(options)`
92
+
93
+ | Option | Default | |
94
+ |---|---|---|
95
+ | `fetch` | `globalThis.fetch` | any WHATWG fetch |
96
+ | `store` | in memory | `{ get(): Promise<doc \| null>, set(doc): Promise<void> }`; `fileStore(path)` keeps it in a JSON file |
97
+ | `sources` | `SOURCES` | `[{ agent, url }]`, which lists to fetch |
98
+ | `now` | `Date.now` | a clock, for tests |
99
+ | `log` | `console` | `{ warn, error }`; pass `{}` for silence |
100
+ | `isKnownScannerIp` | none | `(ip) => boolean`, the 444 rule (below) |
101
+ | `feedUrl` | none | one merged document instead of the vendor URLs (below) |
102
+
103
+ It returns:
104
+
105
+ - **`refresh({ force })`** reads the store, fetches every list that is due, drops what is too old and
106
+ writes back. It never throws and returns `{ fetched, failed, fresh, dropped, errors }` (agent keys;
107
+ `errors` maps a failed agent to its reason). `force` refetches lists that are still fresh.
108
+ - **`check(ip, ua)`** is synchronous and uses the lists the last `refresh()` or `load()` saw. It
109
+ returns `{ agent, kind, assistant, purpose, source, verdict, reason }`:
110
+
111
+ | verdict | reason | when |
112
+ |---|---|---|
113
+ | `verified` | `in-vendor-list` | the IP is in the list for the agent the UA claims |
114
+ | `spoofed` | `not-in-vendor-list` | that agent has a list and the IP is not in it |
115
+ | `spoofed` | `robots-token-not-a-user-agent` | the UA says Google-Extended (a robots.txt token, never sent as a UA) |
116
+ | `spoofed` | `known-scanner-ip` | no list, and your `isKnownScannerIp(ip)` said yes |
117
+ | `unknown` | `no-vendor-list` | the operator publishes no list (CCBot, YandexBot, Claude Code, …) |
118
+ | `unknown` | `list-unavailable` | it has one, but it could not be loaded and there is no copy younger than 8 days |
119
+ | `unknown` | `invalid-ip` | the IP did not parse |
120
+ | `n/a` | `not-a-known-agent` | the UA claims to be no known AI agent or search crawler (`agent: null`) |
121
+
122
+ `kind` is `'ai'` (an `AGENTS` entry) or `'crawler'` (a `CRAWLERS` entry). An IPv4-mapped IPv6
123
+ address (`::ffff:1.2.3.4`, as a dual-stack listener logs it) is checked as the IPv4 it is.
124
+ - **`load()`** rebuilds the matcher from the store without fetching: for workers that share a store
125
+ with one process that refreshes.
126
+ - **`lists()`** returns `[{ agent, url, prefixes, fetchedAt, fresh }]` in `sources` order.
127
+
128
+ ### The 444 rule, for agents without a list
129
+
130
+ Most operators publish no IP list. For those, `siteread` says `unknown` rather than guess. If you
131
+ know which IPs probed your site for scanner paths, pass that and an `unknown` becomes `spoofed`:
132
+
133
+ ```js
134
+ import { readFileSync } from 'node:fs';
135
+ import { createVerifier, fileStore, isScannerPath } from 'siteread';
136
+
137
+ const lines = readFileSync('access.log', 'utf8').trim().split('\n').map(JSON.parse); // your log format here
138
+ const scanners = new Set(lines.filter(l => isScannerPath(l.request_uri)).map(l => l.remote_addr));
139
+
140
+ const verifier = createVerifier({ store: fileStore('.siteread.json'), isKnownScannerIp: ip => scanners.has(ip) });
141
+ await verifier.refresh();
142
+ for (const l of lines) {
143
+ const r = verifier.check(l.remote_addr, l.http_user_agent);
144
+ if (r.agent) console.log(r.verdict, r.agent, l.request_uri);
145
+ }
146
+ ```
147
+
148
+ The hook only ever turns `unknown` into `spoofed`. When the agent has a vendor list, the list wins,
149
+ even for an IP that hit a scanner path. The ClaudeBot story below is why.
150
+
151
+ ### UA tables and scanner paths
152
+
153
+ - **`AGENTS`** lists 23 AI agents as `{ key, re, assistant, purpose }`, where `purpose` is `answer`
154
+ (fetched live for a person), `search` (an assistant's index) or `training`. The FIRST match wins.
155
+ **`detectAgent(ua)`** returns the entry or `null`.
156
+ - **`CRAWLERS`** lists classic search and SEO crawlers (Googlebot, bingbot, YandexBot, DuckDuckBot,
157
+ Slurp, PetalBot, AhrefsBot, SemrushBot, MJ12bot, facebookexternalhit). **`detectCrawler(ua)`**
158
+ returns the entry, and **`isSearchCrawler(ua)`** returns a boolean. AI agents are never search
159
+ crawlers here: Applebot, GPTBot and the rest are in `AGENTS`.
160
+ - **`isScannerPath(path, { allow })`** is true for a vulnerability-scanner request target: dotfiles
161
+ (but not `/.well-known/`), `.php`/`.asp`/`.jsp`/`.env`/`.sql`/`.bak`/`.sh`…, `wp-*`, phpMyAdmin,
162
+ `cgi-bin`, router exploits, percent-encoded dots (`/%2eenv`), secret and config file names,
163
+ `Dockerfile`. Pass the raw request target (path + query, not URL-decoded: nginx's
164
+ `$request_uri`). If a real path of yours matches, exempt it:
165
+ `isScannerPath(p, { allow: /^\/(install\.sh|admin\/config\.yml)(\?|$)/ })`.
166
+ - **`parseCidr`**, **`parseList`**, **`normIp`** and **`SOURCES`** are exported for tests and extensions.
167
+
168
+ ## Why
169
+
170
+ This code was built to count AI traffic on one small site's nginx log, and every simple approach
171
+ gave the wrong answer.
172
+
173
+ **Scanners wear AI user-agents.** One scanner cycles through them. In 30 days of the log
174
+ (to 2026-10-16), 217 of the 221 ChatGPT-User requests from outside OpenAI's list came from IPs that
175
+ also drew a 444 (nginx's "close, send nothing" reply to a scanner path). Every request claiming to be
176
+ DeepSeekBot, CCBot, Grok, Perplexity-User or Claude-SearchBot was fake. A UA count is a count of
177
+ what scanners chose to say.
178
+
179
+ **The 444 rule cannot clear a real crawler that follows a bad link.** Before Anthropic's list was
180
+ used, the test was "this IP never hit a scanner path." Then ClaudeBot resolved a GitHub README's
181
+ relative links against the site and asked for `/protocol//blob/master/examples/docker-compose.yml`.
182
+ It drew one 444, and all 50 of that IP's reads that day were counted as spoofed. Anthropic publishes
183
+ one list for its bots (`claude.com/crawling/bots.json`). In those 30 days every ClaudeBot IP and
184
+ every page-reading Claude-User IP was in it, and the four IPs probing `/.env`-style paths under
185
+ Claude names were not. Hence the rule: where a list exists, it decides.
186
+
187
+ **Claude Code is not Anthropic's crawler.** Claude Code's WebFetch sends
188
+ `Claude-User (claude-code/…)` from the user's own machine and IP, so no list can cover it. It is
189
+ matched before `Claude-User` and always comes back `unknown`.
190
+
191
+ **Google-Extended is not a User-Agent.** It is a robots.txt token that Google's crawlers obey while
192
+ fetching as Googlebot. A request that sends it as a UA is not from Google.
193
+
194
+ ## Vendor lists
195
+
196
+ | Agent(s) | List |
197
+ |---|---|
198
+ | chatgpt-user | https://openai.com/chatgpt-user.json |
199
+ | oai-searchbot | https://openai.com/searchbot.json |
200
+ | gptbot | https://openai.com/gptbot.json |
201
+ | perplexity-user | https://www.perplexity.com/perplexity-user.json |
202
+ | perplexitybot | https://www.perplexity.com/perplexitybot.json |
203
+ | applebot | https://search.developer.apple.com/applebot.json |
204
+ | claude-user, claude-searchbot, claudebot | https://claude.com/crawling/bots.json (fetched once for all three) |
205
+ | googlebot | https://developers.google.com/static/search/apis/ipranges/googlebot.json |
206
+ | bingbot | https://www.bing.com/toolbox/bingbot.json |
207
+
208
+ All of them answer `{ "creationTime": …, "prefixes": [{ "ipv4Prefix": … } | { "ipv6Prefix": … }] }`.
209
+
210
+ - **Refresh:** a list is refetched only when the stored copy is more than 24 h old.
211
+ - **Failure:** a failed fetch keeps the previous copy, so a vendor's CDN hiccup does not turn its real
212
+ crawlers into `spoofed`. A 200 that is not JSON (an HTML challenge page, a moved list) counts as a
213
+ failure, not as an empty list.
214
+ - **Drop:** a copy nobody could refresh for 8 days is dropped, so a vendor's moved ranges cannot mark
215
+ its new IPs as spoofed forever. With no copy, the verdict is `unknown`, never `spoofed`.
216
+ - **Bounds:** a body over 2,000,000 characters, more than 20,000 prefixes, or a prefix broader than
217
+ /8 (IPv4) or /16 (IPv6) is rejected. A `/0` would verify every address.
218
+
219
+ ## Privacy
220
+
221
+ Local use sends nothing anywhere except plain GETs of the URLs above, with the User-Agent
222
+ `siteread/<version>`. No hostnames, URLs or visitor IPs leave your machine. There is no reverse-DNS
223
+ check for the same reason: a PTR lookup sends the visitor's IP to your resolver.
224
+
225
+ `feedUrl` points the verifier at one merged document,
226
+ `{ "lists": { "<agent>": { "prefixes": [...], "fetchedAt": "..." } } }`, instead of the vendors. It
227
+ is for a hosted feed that is planned but **not live yet**, so it is off by default and strictly opt-in.
228
+ Every prefix from a feed is re-validated like a vendor's, and a `fetchedAt` in the future is clamped
229
+ to now.
230
+
231
+ ## License
232
+
233
+ MIT. Extracted from the pipeline Synacl Labs runs on synacl.com.
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ // siteread CLI. No dependencies; arguments parsed by hand.
3
+ //
4
+ // siteread check <ip> "<user-agent>" exit 0 verified · 2 spoofed · 3 unknown / n/a · 1 usage or error
5
+ // siteread lists
6
+ // siteread digest --gsc <export> --log <file> … JSON for the seo-read skill's weekly memo
7
+ // siteread --help
8
+ //
9
+ // Lists are cached in one file (os.tmpdir()/siteread/lists.json, or $SITEREAD_CACHE) and
10
+ // refetched only when older than 24 h. The only network traffic is GETs of the vendors' lists.
11
+
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
15
+ import { createVerifier, fileStore, detectAgent, detectCrawler, digest, parseGoal, VERSION } from '../src/index.js';
16
+
17
+ const EXIT = { verified: 0, spoofed: 2, unknown: 3, 'n/a': 3, usage: 1 };
18
+ const cacheFile = process.env.SITEREAD_CACHE || path.join(os.tmpdir(), 'siteread', 'lists.json');
19
+
20
+ const HELP = `siteread ${VERSION}: is that crawler who it says it is?
21
+
22
+ Usage:
23
+ siteread check <ip> "<user-agent>" verdict for one request
24
+ siteread lists every vendor list: prefixes and when it was fetched
25
+ siteread digest [options] a Search Console export + access log -> JSON numbers
26
+ for a weekly read (the seo-read skill writes the memo)
27
+ siteread --help | --version
28
+
29
+ Options:
30
+ --no-cache refetch every list now (a copy younger than 24 h is otherwise reused;
31
+ a failed fetch still falls back to the cached copy)
32
+ --json print the full result as JSON
33
+
34
+ Digest options (at least one of --gsc / --log):
35
+ --gsc <zip|dir> Search Console Performance export (Export -> Download CSV)
36
+ --log <file> access log, nginx/Apache combined, .gz ok (repeat for several)
37
+ --site <host> your site's host (guessed from Referers otherwise)
38
+ --sitemap <file> a local sitemap.xml: count crawler fetches on its URLs only
39
+ --from / --to <day> keep only log days in this range (YYYY-MM-DD)
40
+ --split <day> GSC before/after comparison around a change made that day
41
+ --goal <label=regex> count people whose "METHOD /path status" matches (repeatable),
42
+ e.g. --goal 'signup=^POST /signup 2'
43
+ --team-path <regex> an IP that got a 2xx on such a path is your team, not a reader
44
+ --exclude-ip <ip,...> more IPs that are you (repeatable)
45
+ --allow-path <regex> your real paths that look like scanner probes (/install.sh)
46
+ --tz <zone> zone for log days (default America/Los_Angeles, GSC's)
47
+ --prev <digest.json> an earlier digest: GSC rows gain last read's numbers
48
+ --out <dir> write <dir>/<last day>.json instead of stdout, and use the newest
49
+ earlier digest in <dir> as --prev unless --prev is given
50
+ --limit <n> rows per table (default 25)
51
+ --offline use cached vendor lists only; no network at all
52
+
53
+ Exit codes (check):
54
+ 0 verified the IP is in the vendor's published list for that agent
55
+ 2 spoofed the vendor publishes a list and the IP is not in it
56
+ 3 unknown no list to check against, or the UA is not a known AI agent or crawler
57
+ 1 usage error, or not an IP address
58
+
59
+ Cache: ${cacheFile} (override with SITEREAD_CACHE)
60
+ Nothing leaves this machine except GETs of the vendors' public IP lists.`;
61
+
62
+ // Options that take a value (digest only). Each keeps every value given, in order.
63
+ const VALUE_OPTS = new Set(['--gsc', '--log', '--out', '--site', '--sitemap', '--from', '--to', '--split', '--goal',
64
+ '--team-path', '--exclude-ip', '--allow-path', '--tz', '--prev', '--limit']);
65
+
66
+ function parseArgs(argv) {
67
+ const flags = new Set();
68
+ const opts = new Map();
69
+ const pos = [];
70
+ for (let i = 0; i < argv.length; i++) {
71
+ let a = argv[i];
72
+ if (a === '--') { pos.push(...argv.slice(i + 1)); break; }
73
+ let val;
74
+ const eq = a.indexOf('=');
75
+ if (a.startsWith('--') && eq > 0 && VALUE_OPTS.has(a.slice(0, eq))) { val = a.slice(eq + 1); a = a.slice(0, eq); }
76
+ if (VALUE_OPTS.has(a)) {
77
+ if (val === undefined) {
78
+ if (i + 1 >= argv.length) throw new UsageError(`${a} needs a value`);
79
+ val = argv[++i];
80
+ }
81
+ if (!opts.has(a)) opts.set(a, []);
82
+ opts.get(a).push(val);
83
+ } else if (a.startsWith('--') || a === '-h' || a === '-v') flags.add(a);
84
+ else pos.push(a);
85
+ }
86
+ return { flags, opts, pos };
87
+ }
88
+
89
+ class UsageError extends Error {}
90
+
91
+ function usage(msg) {
92
+ process.stderr.write(`siteread: ${msg}\nRun "siteread --help" for usage.\n`);
93
+ return EXIT.usage;
94
+ }
95
+
96
+ function when(iso) {
97
+ return iso ? iso.replace(/\.\d{3}Z$/, 'Z') : 'never';
98
+ }
99
+
100
+ // Short host+path for one-line output: https://claude.com/crawling/bots.json → claude.com/crawling/bots.json
101
+ function shortUrl(u) {
102
+ return String(u || '').replace(/^https?:\/\//, '');
103
+ }
104
+
105
+ function reportFailures(result, verifier) {
106
+ const failed = [...new Set(result.failed)];
107
+ if (!failed.length && !result.error) return;
108
+ const held = new Map(verifier.lists().map(l => [l.agent, l]));
109
+ for (const agent of failed) {
110
+ const l = held.get(agent);
111
+ const kept = l?.prefixes ? `kept the copy from ${when(l.fetchedAt)}` : 'no copy to fall back on';
112
+ process.stderr.write(`siteread: could not refresh ${agent}: ${result.errors?.[agent] || 'failed'} (${kept})\n`);
113
+ }
114
+ if (result.error) process.stderr.write(`siteread: cache ${cacheFile}: ${result.error}\n`);
115
+ }
116
+
117
+ function describe(ip, ua, r, list) {
118
+ const who = r.assistant ? `${r.agent} (${r.assistant}, ${r.purpose})` : `${r.agent} (${r.purpose})`;
119
+ switch (r.reason) {
120
+ case 'in-vendor-list':
121
+ return `verified ${ip} is ${who}: in ${shortUrl(r.source)} (${list.prefixes} prefixes, fetched ${when(list.fetchedAt)})`;
122
+ case 'not-in-vendor-list':
123
+ return `spoofed ${ip} claims ${who} but is not in ${shortUrl(r.source)} (${list.prefixes} prefixes, fetched ${when(list.fetchedAt)})`;
124
+ case 'robots-token-not-a-user-agent':
125
+ return `spoofed ${ip} claims ${who}: Google-Extended is a robots.txt token, never a real user-agent`;
126
+ case 'known-scanner-ip':
127
+ return `spoofed ${ip} claims ${who} and has probed scanner paths`;
128
+ case 'list-unavailable':
129
+ return `unknown ${ip} claims ${who}: could not load ${shortUrl(r.source)} and no cached copy`;
130
+ case 'no-vendor-list': {
131
+ const e = detectAgent(ua) || detectCrawler(ua);
132
+ const why = e?.note ? `${e.note}` : 'its operator publishes no IP list';
133
+ return `unknown ${ip} claims ${who}: ${why}. If this IP also requested scanner paths in your log, treat it as spoofed`;
134
+ }
135
+ default:
136
+ return `n/a "${ua}" is not a known AI agent or search crawler`;
137
+ }
138
+ }
139
+
140
+ async function cmdCheck({ flags, pos }) {
141
+ if (pos.length !== 3) return usage('check takes exactly two arguments: <ip> "<user-agent>" (quote the user-agent)');
142
+ const [, ip, ua] = pos;
143
+ const verifier = createVerifier({ store: fileStore(cacheFile), log: {} });
144
+ let r = verifier.check(ip, ua);
145
+ if (r.reason === 'invalid-ip') return usage(`"${ip}" is not an IP address`);
146
+ // Only touch the network when the claimed agent has a list to check against.
147
+ if (r.source) {
148
+ const result = await verifier.refresh({ force: flags.has('--no-cache') });
149
+ reportFailures(result, verifier);
150
+ r = verifier.check(ip, ua);
151
+ }
152
+ const list = verifier.lists().find(l => l.agent === r.agent) || {};
153
+ if (flags.has('--json')) {
154
+ process.stdout.write(`${JSON.stringify({ ip, userAgent: ua, ...r, list: r.source ? list : null }, null, 2)}\n`);
155
+ } else {
156
+ process.stdout.write(`${describe(ip, ua, r, list)}\n`);
157
+ }
158
+ return EXIT[r.verdict] ?? EXIT.usage;
159
+ }
160
+
161
+ async function cmdLists({ flags, pos }) {
162
+ if (pos.length !== 1) return usage('lists takes no arguments');
163
+ const verifier = createVerifier({ store: fileStore(cacheFile), log: {} });
164
+ const result = await verifier.refresh({ force: flags.has('--no-cache') });
165
+ reportFailures(result, verifier);
166
+ const rows = verifier.lists();
167
+ if (flags.has('--json')) {
168
+ process.stdout.write(`${JSON.stringify({ cache: cacheFile, lists: rows }, null, 2)}\n`);
169
+ } else {
170
+ const w = Math.max(...rows.map(l => l.agent.length), 5);
171
+ const out = [`${'agent'.padEnd(w)} prefixes fetched (UTC) source`];
172
+ for (const l of rows) {
173
+ out.push(`${l.agent.padEnd(w)} ${String(l.prefixes).padStart(8)} ${when(l.fetchedAt).padEnd(20)} ${l.url}`);
174
+ }
175
+ process.stdout.write(`${out.join('\n')}\n`);
176
+ }
177
+ return rows.some(l => l.prefixes) ? 0 : 1;
178
+ }
179
+
180
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
181
+
182
+ async function cmdDigest({ flags, opts, pos }) {
183
+ if (pos.length !== 1) return usage('digest takes options only, e.g. digest --gsc export.zip --log access.log');
184
+ const one = k => opts.get(k)?.at(-1) ?? null;
185
+ const logs = opts.get('--log') ?? [];
186
+ const gsc = one('--gsc');
187
+ if (!gsc && !logs.length) return usage('digest needs --gsc <export>, --log <file>, or both');
188
+ for (const k of ['--from', '--to', '--split']) if (one(k) && !DAY_RE.test(one(k))) return usage(`${k} wants YYYY-MM-DD`);
189
+ const re = (k) => {
190
+ if (!one(k)) return null;
191
+ try { return new RegExp(one(k), 'i'); } catch (err) { throw new UsageError(`${k}: ${err.message}`); }
192
+ };
193
+ let goals;
194
+ try { goals = (opts.get('--goal') ?? []).map(parseGoal); } catch (err) { return usage(err.message); }
195
+ const tz = one('--tz') ?? 'America/Los_Angeles';
196
+ try { new Intl.DateTimeFormat('en', { timeZone: tz }); } catch { return usage(`--tz: unknown time zone "${tz}"`); }
197
+ const limit = one('--limit') ? Number(one('--limit')) : 25;
198
+ if (!Number.isInteger(limit) || limit < 1) return usage('--limit wants a positive whole number');
199
+ let prev = null;
200
+ if (one('--prev')) {
201
+ try { prev = JSON.parse(readFileSync(one('--prev'), 'utf8')); } catch (err) { return usage(`--prev: ${err.message}`); }
202
+ }
203
+
204
+ let verifier = null;
205
+ if (logs.length) {
206
+ verifier = createVerifier({ store: fileStore(cacheFile), log: {} });
207
+ if (flags.has('--offline')) {
208
+ await verifier.load();
209
+ } else {
210
+ reportFailures(await verifier.refresh({ force: flags.has('--no-cache') }), verifier);
211
+ }
212
+ if (!verifier.lists().some(l => l.prefixes)) {
213
+ process.stderr.write('siteread: no vendor lists available; every crawler will read as unknown\n');
214
+ }
215
+ }
216
+
217
+ const out = await digest({
218
+ gsc, logs, verifier, prev, limit,
219
+ split: one('--split'), sitemapFile: one('--sitemap'),
220
+ site: one('--site'), tz, from: one('--from'), to: one('--to'), goals,
221
+ teamPath: re('--team-path'), allowPath: re('--allow-path'),
222
+ excludeIps: new Set((opts.get('--exclude-ip') ?? []).flatMap(s => s.split(',')).map(s => s.trim()).filter(Boolean)),
223
+ });
224
+ if (verifier) out.vendorLists = verifier.lists().map(({ agent, prefixes, fetchedAt }) => ({ agent, prefixes, fetchedAt }));
225
+ for (const w of out.warnings) process.stderr.write(`siteread: ${w}\n`);
226
+ const dir = one('--out');
227
+ if (!dir) {
228
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
229
+ return 0;
230
+ }
231
+ // Named for the last day the read covers, which is only known now (GSC's lag is cut first).
232
+ const name = `${out.gsc?.range.to ?? out.log?.range.to ?? 'empty'}.json`;
233
+ mkdirSync(dir, { recursive: true });
234
+ if (!prev && gsc) {
235
+ const earlier = readdirSync(dir).filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f < name).sort().at(-1);
236
+ if (earlier) {
237
+ const file = path.join(dir, earlier);
238
+ const again = await digest({ gsc, prev: JSON.parse(readFileSync(file, 'utf8')), split: one('--split'), limit });
239
+ out.gsc = again.gsc;
240
+ out.prevFile = earlier;
241
+ }
242
+ }
243
+ const file = path.join(dir, name);
244
+ writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
245
+ process.stderr.write(`siteread: wrote ${file}${out.prevFile ? ` (compared with ${out.prevFile})` : ' (no earlier digest to compare with)'}\n`);
246
+ process.stdout.write(`${file}\n`);
247
+ return 0;
248
+ }
249
+
250
+ async function main(argv) {
251
+ let args;
252
+ try { args = parseArgs(argv); } catch (err) { if (err instanceof UsageError) return usage(err.message); throw err; }
253
+ const { flags, pos } = args;
254
+ if (flags.has('--help') || flags.has('-h') || pos[0] === 'help') { process.stdout.write(`${HELP}\n`); return 0; }
255
+ if (flags.has('--version') || flags.has('-v')) { process.stdout.write(`${VERSION}\n`); return 0; }
256
+ const known = new Set(['--no-cache', '--json', '--offline']);
257
+ const bad = [...flags].find(f => !known.has(f));
258
+ if (bad) return usage(`unknown option ${bad}`);
259
+ if (pos[0] === 'check') return cmdCheck(args);
260
+ if (pos[0] === 'lists') return cmdLists(args);
261
+ if (pos[0] === 'digest') {
262
+ try { return await cmdDigest(args); } catch (err) {
263
+ if (err instanceof UsageError) return usage(err.message);
264
+ if (err?.code === 'ENOENT') return usage(`no such file: ${err.path}`);
265
+ // Bad input (an export with no Dates.csv, a broken zip): say what, no stack trace.
266
+ process.stderr.write(`siteread: digest: ${err?.message || err}\n`);
267
+ return 1;
268
+ }
269
+ }
270
+ if (!pos.length) { process.stdout.write(`${HELP}\n`); return EXIT.usage; }
271
+ return usage(`unknown command "${pos[0]}"`);
272
+ }
273
+
274
+ main(process.argv.slice(2)).then(
275
+ (code) => { process.exitCode = code; },
276
+ (err) => { process.stderr.write(`siteread: ${err?.stack || err}\n`); process.exitCode = 1; },
277
+ );
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "siteread",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Is that crawler who it says it is? Checks an IP + User-Agent against the AI and search vendors' published IP lists. Zero dependencies.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "bin": {
10
+ "siteread": "./bin/siteread.js"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "bin",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test"
23
+ },
24
+ "keywords": [
25
+ "crawler",
26
+ "bot",
27
+ "verification",
28
+ "gptbot",
29
+ "claudebot",
30
+ "chatgpt-user",
31
+ "googlebot",
32
+ "user-agent",
33
+ "ip-ranges",
34
+ "access-log"
35
+ ],
36
+ "license": "MIT"
37
+ }