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.
@@ -0,0 +1,460 @@
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 share [<digest>] opt-in: one week's aggregates → the benchmark, where you stand back
8
+ // siteread --help
9
+ //
10
+ // Lists are cached in one file (os.tmpdir()/siteread/lists.json, or $SITEREAD_CACHE) and
11
+ // refetched only when older than 24 h, from the siteread feed (one GET of public data; the service
12
+ // keeps no address) or, with --no-feed and whenever the feed fails, from each vendor. The only
13
+ // other traffic is `share`, which prints the exact bytes it would send and sends only on a yes.
14
+
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { createHash, randomBytes } from 'node:crypto';
18
+ import { createInterface } from 'node:readline/promises';
19
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
20
+ import { createVerifier, fileStore, detectAgent, detectCrawler, digest, parseGoal, sharePayload, SHARE_METRICS, SHARE_URL, FEED_URL, VERSION } from '../src/index.js';
21
+ import { leadingZeroBits, PROOF_BASE_BITS, PROOF_PREFIX } from '../src/share.js';
22
+
23
+ const EXIT = { verified: 0, spoofed: 2, unknown: 3, 'n/a': 3, usage: 1 };
24
+ const cacheFile = process.env.SITEREAD_CACHE || path.join(os.tmpdir(), 'siteread', 'lists.json');
25
+
26
+ const HELP = `siteread ${VERSION}: is that crawler who it says it is?
27
+
28
+ Usage:
29
+ siteread check <ip> "<user-agent>" verdict for one request
30
+ siteread lists every vendor list: prefixes and when it was fetched
31
+ siteread digest [options] a Search Console export + access log -> JSON numbers
32
+ for a weekly read (the seo-read skill writes the memo)
33
+ siteread share [<digest.json|dir>] opt-in: send one week of a digest's aggregate numbers
34
+ to the public benchmark and see where you stand
35
+ (default: the newest digest in .siteread/digests)
36
+ siteread --help | --version
37
+
38
+ Options:
39
+ --no-cache refetch every list now (a copy younger than 24 h is otherwise reused;
40
+ a failed fetch still falls back to the cached copy)
41
+ --no-feed fetch each list from its vendor instead of the siteread feed
42
+ (also SITEREAD_FEED_URL=off; SITEREAD_FEED_URL=<url> reads another feed)
43
+ --json print the full result as JSON
44
+
45
+ Digest options (at least one of --gsc / --log):
46
+ --gsc <zip|dir> Search Console Performance export (Export -> Download CSV)
47
+ --log <file> access log: nginx/Apache combined, Caddy JSON or nginx JSON, .gz ok
48
+ (repeat for several)
49
+ --host <name> keep only requests to this host, for a log of several sites (repeatable;
50
+ JSON logs carry the host, combined does not)
51
+ --site <host> your site's host (guessed from Referers otherwise)
52
+ --sitemap <file> a local sitemap.xml: count crawler fetches on its URLs only
53
+ --from / --to <day> keep only log days in this range (YYYY-MM-DD)
54
+ --split <day> GSC before/after comparison around a change made that day
55
+ --goal <label=regex> count people whose "METHOD /path status" matches (repeatable),
56
+ e.g. --goal 'signup=^POST /signup 2'
57
+ --team-path <regex> an IP that got a 2xx on such a path is your team, not a reader
58
+ --exclude-ip <ip,...> more IPs that are you (repeatable)
59
+ --allow-path <regex> your real paths that look like scanner probes (/install.sh)
60
+ --tz <zone> zone for log days (default America/Los_Angeles, GSC's)
61
+ --prev <digest.json> an earlier digest: GSC rows gain last read's numbers
62
+ --out <dir> write <dir>/<last day>.json instead of stdout, and use the newest
63
+ earlier digest in <dir> as --prev unless --prev is given
64
+ --limit <n> rows per table (default 25)
65
+ --offline use cached vendor lists only; no network at all
66
+
67
+ Share options (prints the exact payload first, then asks; never hostnames, URLs or IPs):
68
+ --dry-run print the payload and stop
69
+ --yes send without asking (scripts; without a terminal it is required)
70
+ --endpoint <url> where to send it (default ${SHARE_URL}, or $SITEREAD_SHARE_URL)
71
+ --id-file <file> this site's random id (default .siteread/share-id, made on first use;
72
+ delete it to start over as a new site)
73
+
74
+ Exit codes (check):
75
+ 0 verified the IP is in the vendor's published list for that agent
76
+ 2 spoofed the vendor publishes a list and the IP is not in it
77
+ 3 unknown no list to check against, or the UA is not a known AI agent or crawler
78
+ 1 usage error, or not an IP address
79
+
80
+ Cache: ${cacheFile} (override with SITEREAD_CACHE)
81
+ Nothing leaves this machine except GETs of public crawler lists (the siteread feed, or with
82
+ --no-feed the vendors' own), and a share you approve.`;
83
+
84
+ // Options that take a value (digest only). Each keeps every value given, in order.
85
+ const VALUE_OPTS = new Set(['--gsc', '--log', '--out', '--site', '--sitemap', '--from', '--to', '--split', '--goal',
86
+ '--team-path', '--exclude-ip', '--allow-path', '--tz', '--prev', '--limit', '--endpoint', '--id-file', '--host']);
87
+
88
+ function parseArgs(argv) {
89
+ const flags = new Set();
90
+ const opts = new Map();
91
+ const pos = [];
92
+ for (let i = 0; i < argv.length; i++) {
93
+ let a = argv[i];
94
+ if (a === '--') { pos.push(...argv.slice(i + 1)); break; }
95
+ let val;
96
+ const eq = a.indexOf('=');
97
+ if (a.startsWith('--') && eq > 0 && VALUE_OPTS.has(a.slice(0, eq))) { val = a.slice(eq + 1); a = a.slice(0, eq); }
98
+ if (VALUE_OPTS.has(a)) {
99
+ if (val === undefined) {
100
+ if (i + 1 >= argv.length) throw new UsageError(`${a} needs a value`);
101
+ val = argv[++i];
102
+ }
103
+ if (!opts.has(a)) opts.set(a, []);
104
+ opts.get(a).push(val);
105
+ } else if (a.startsWith('--') || a === '-h' || a === '-v') flags.add(a);
106
+ else pos.push(a);
107
+ }
108
+ return { flags, opts, pos };
109
+ }
110
+
111
+ class UsageError extends Error {}
112
+
113
+ function usage(msg) {
114
+ process.stderr.write(`siteread: ${msg}\nRun "siteread --help" for usage.\n`);
115
+ return EXIT.usage;
116
+ }
117
+
118
+ function when(iso) {
119
+ return iso ? iso.replace(/\.\d{3}Z$/, 'Z') : 'never';
120
+ }
121
+
122
+ // Short host+path for one-line output: https://claude.com/crawling/bots.json → claude.com/crawling/bots.json
123
+ function shortUrl(u) {
124
+ return String(u || '').replace(/^https?:\/\//, '');
125
+ }
126
+
127
+ function reportFailures(result, verifier) {
128
+ const failed = [...new Set(result.failed)];
129
+ if (!failed.length && !result.error) return;
130
+ const held = new Map(verifier.lists().map(l => [l.agent, l]));
131
+ for (const agent of failed) {
132
+ const l = held.get(agent);
133
+ const kept = l?.prefixes ? `kept the copy from ${when(l.fetchedAt)}` : 'no copy to fall back on';
134
+ process.stderr.write(`siteread: could not refresh ${agent}: ${result.errors?.[agent] || 'failed'} (${kept})\n`);
135
+ }
136
+ if (result.error) process.stderr.write(`siteread: cache ${cacheFile}: ${result.error}\n`);
137
+ }
138
+
139
+ function describe(ip, ua, r, list) {
140
+ const who = r.assistant ? `${r.agent} (${r.assistant}, ${r.purpose})` : `${r.agent} (${r.purpose})`;
141
+ switch (r.reason) {
142
+ case 'in-vendor-list':
143
+ return `verified ${ip} is ${who}: in ${shortUrl(r.source)} (${list.prefixes} prefixes, fetched ${when(list.fetchedAt)})`;
144
+ case 'not-in-vendor-list':
145
+ return `spoofed ${ip} claims ${who} but is not in ${shortUrl(r.source)} (${list.prefixes} prefixes, fetched ${when(list.fetchedAt)})`;
146
+ case 'robots-token-not-a-user-agent':
147
+ return `spoofed ${ip} claims ${who}: Google-Extended is a robots.txt token, never a real user-agent`;
148
+ case 'known-scanner-ip':
149
+ return `spoofed ${ip} claims ${who} and has probed scanner paths`;
150
+ case 'list-unavailable':
151
+ return `unknown ${ip} claims ${who}: could not load ${shortUrl(r.source)} and no cached copy`;
152
+ case 'no-vendor-list': {
153
+ const e = detectAgent(ua) || detectCrawler(ua);
154
+ const why = e?.note ? `${e.note}` : 'its operator publishes no IP list';
155
+ return `unknown ${ip} claims ${who}: ${why}. If this IP also requested scanner paths in your log, treat it as spoofed`;
156
+ }
157
+ default:
158
+ return `n/a "${ua}" is not a known AI agent or search crawler`;
159
+ }
160
+ }
161
+
162
+ // The feed by default, as the charter has it once the feed exists: one GET instead of nine, of
163
+ // public data, from a service that keeps no address. What it cannot give comes from the vendors.
164
+ function newVerifier(flags) {
165
+ const env = process.env.SITEREAD_FEED_URL;
166
+ const feedUrl = flags.has('--no-feed') || env === 'off' ? null : (env || FEED_URL);
167
+ return createVerifier({ store: fileStore(cacheFile), log: {}, feedUrl, fallback: true });
168
+ }
169
+
170
+ async function cmdCheck({ flags, pos }) {
171
+ if (pos.length !== 3) return usage('check takes exactly two arguments: <ip> "<user-agent>" (quote the user-agent)');
172
+ const [, ip, ua] = pos;
173
+ const verifier = newVerifier(flags);
174
+ let r = verifier.check(ip, ua);
175
+ if (r.reason === 'invalid-ip') return usage(`"${ip}" is not an IP address`);
176
+ // Only touch the network when the claimed agent has a list to check against.
177
+ if (r.source) {
178
+ const result = await verifier.refresh({ force: flags.has('--no-cache') });
179
+ reportFailures(result, verifier);
180
+ r = verifier.check(ip, ua);
181
+ }
182
+ const list = verifier.lists().find(l => l.agent === r.agent) || {};
183
+ if (flags.has('--json')) {
184
+ process.stdout.write(`${JSON.stringify({ ip, userAgent: ua, ...r, list: r.source ? list : null }, null, 2)}\n`);
185
+ } else {
186
+ process.stdout.write(`${describe(ip, ua, r, list)}\n`);
187
+ }
188
+ return EXIT[r.verdict] ?? EXIT.usage;
189
+ }
190
+
191
+ async function cmdLists({ flags, pos }) {
192
+ if (pos.length !== 1) return usage('lists takes no arguments');
193
+ const verifier = newVerifier(flags);
194
+ const result = await verifier.refresh({ force: flags.has('--no-cache') });
195
+ reportFailures(result, verifier);
196
+ const rows = verifier.lists();
197
+ if (flags.has('--json')) {
198
+ process.stdout.write(`${JSON.stringify({ cache: cacheFile, lists: rows }, null, 2)}\n`);
199
+ } else {
200
+ const w = Math.max(...rows.map(l => l.agent.length), 5);
201
+ const out = [`${'agent'.padEnd(w)} prefixes fetched (UTC) source`];
202
+ for (const l of rows) {
203
+ out.push(`${l.agent.padEnd(w)} ${String(l.prefixes).padStart(8)} ${when(l.fetchedAt).padEnd(20)} ${l.url}`);
204
+ }
205
+ process.stdout.write(`${out.join('\n')}\n`);
206
+ }
207
+ return rows.some(l => l.prefixes) ? 0 : 1;
208
+ }
209
+
210
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
211
+
212
+ async function cmdDigest({ flags, opts, pos }) {
213
+ if (pos.length !== 1) return usage('digest takes options only, e.g. digest --gsc export.zip --log access.log');
214
+ const one = k => opts.get(k)?.at(-1) ?? null;
215
+ const logs = opts.get('--log') ?? [];
216
+ const gsc = one('--gsc');
217
+ if (!gsc && !logs.length) return usage('digest needs --gsc <export>, --log <file>, or both');
218
+ for (const k of ['--from', '--to', '--split']) if (one(k) && !DAY_RE.test(one(k))) return usage(`${k} wants YYYY-MM-DD`);
219
+ const re = (k) => {
220
+ if (!one(k)) return null;
221
+ try { return new RegExp(one(k), 'i'); } catch (err) { throw new UsageError(`${k}: ${err.message}`); }
222
+ };
223
+ let goals;
224
+ try { goals = (opts.get('--goal') ?? []).map(parseGoal); } catch (err) { return usage(err.message); }
225
+ const tz = one('--tz') ?? 'America/Los_Angeles';
226
+ try { new Intl.DateTimeFormat('en', { timeZone: tz }); } catch { return usage(`--tz: unknown time zone "${tz}"`); }
227
+ const limit = one('--limit') ? Number(one('--limit')) : 25;
228
+ if (!Number.isInteger(limit) || limit < 1) return usage('--limit wants a positive whole number');
229
+ let prev = null;
230
+ if (one('--prev')) {
231
+ try { prev = JSON.parse(readFileSync(one('--prev'), 'utf8')); } catch (err) { return usage(`--prev: ${err.message}`); }
232
+ }
233
+
234
+ let verifier = null;
235
+ if (logs.length) {
236
+ verifier = newVerifier(flags);
237
+ if (flags.has('--offline')) {
238
+ await verifier.load();
239
+ } else {
240
+ reportFailures(await verifier.refresh({ force: flags.has('--no-cache') }), verifier);
241
+ }
242
+ if (!verifier.lists().some(l => l.prefixes)) {
243
+ process.stderr.write('siteread: no vendor lists available; every crawler will read as unknown\n');
244
+ }
245
+ }
246
+
247
+ const out = await digest({
248
+ gsc, logs, verifier, prev, limit,
249
+ split: one('--split'), sitemapFile: one('--sitemap'),
250
+ site: one('--site'), tz, from: one('--from'), to: one('--to'), goals,
251
+ teamPath: re('--team-path'), allowPath: re('--allow-path'),
252
+ excludeIps: new Set((opts.get('--exclude-ip') ?? []).flatMap(s => s.split(',')).map(s => s.trim()).filter(Boolean)),
253
+ hosts: opts.get('--host') ?? [],
254
+ });
255
+ if (verifier) out.vendorLists = verifier.lists().map(({ agent, prefixes, fetchedAt }) => ({ agent, prefixes, fetchedAt }));
256
+ for (const w of out.warnings) process.stderr.write(`siteread: ${w}\n`);
257
+ const dir = one('--out');
258
+ if (!dir) {
259
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
260
+ return 0;
261
+ }
262
+ // Named for the last day the read covers, which is only known now (GSC's lag is cut first).
263
+ const name = `${out.gsc?.range.to ?? out.log?.range.to ?? 'empty'}.json`;
264
+ mkdirSync(dir, { recursive: true });
265
+ if (!prev && gsc) {
266
+ const earlier = readdirSync(dir).filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f < name).sort().at(-1);
267
+ if (earlier) {
268
+ const file = path.join(dir, earlier);
269
+ const again = await digest({ gsc, prev: JSON.parse(readFileSync(file, 'utf8')), split: one('--split'), limit });
270
+ out.gsc = again.gsc;
271
+ out.prevFile = earlier;
272
+ }
273
+ }
274
+ const file = path.join(dir, name);
275
+ writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
276
+ process.stderr.write(`siteread: wrote ${file}${out.prevFile ? ` (compared with ${out.prevFile})` : ' (no earlier digest to compare with)'}\n`);
277
+ process.stdout.write(`${file}\n`);
278
+ return 0;
279
+ }
280
+
281
+ const USER_AGENT = `siteread/${VERSION} (+https://www.npmjs.com/package/siteread)`;
282
+ const DIGEST_RE = /^\d{4}-\d{2}-\d{2}\.json$/;
283
+ // A server's words go to the terminal: no escape sequences from someone else's endpoint.
284
+ const clean = s => String(s ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
285
+ const BAND_TEXT = Object.freeze({
286
+ 'below-p25': 'below the 25th percentile', 'p25-p50': '25th to 50th percentile',
287
+ 'p50-p75': '50th to 75th percentile', 'p75-up': '75th percentile or above',
288
+ });
289
+
290
+ /** The site's random share id, made (0600) on first use. */
291
+ function shareId(file) {
292
+ let id;
293
+ try { id = readFileSync(file, 'utf8').trim(); } catch (err) {
294
+ if (err.code !== 'ENOENT') throw err;
295
+ id = randomBytes(16).toString('hex');
296
+ mkdirSync(path.dirname(file), { recursive: true });
297
+ writeFileSync(file, `${id}\n`, { mode: 0o600, flag: 'wx' });
298
+ process.stderr.write(`siteread: made a random id for this site in ${file} (delete it to start over as a new site)\n`);
299
+ }
300
+ if (!/^[0-9a-f]{32}$/.test(id)) throw new UsageError(`${file} does not hold a share id; delete it to make a new one`);
301
+ return id;
302
+ }
303
+
304
+ // A new site's proof of work (src/share.js, PROOF OF WORK), kept next to the id as "nonce bits id"
305
+ // so it is made once. A file for another id, or one that does not hold up, is made again.
306
+ function readProof(file, id) {
307
+ let parts;
308
+ try { parts = readFileSync(file, 'utf8').trim().split(/\s+/); } catch { return null; }
309
+ const [nonce, bits, forId] = parts;
310
+ if (forId !== id || !/^\d{1,20}$/.test(nonce ?? '')) return null;
311
+ const got = leadingZeroBits(createHash('sha256').update(`${PROOF_PREFIX}${id}:${nonce}`).digest());
312
+ return got >= Number(bits) ? { nonce, bits: got } : null;
313
+ }
314
+
315
+ function makeProof(file, id, bits) {
316
+ process.stderr.write(`siteread: making this site's proof of work (${bits} bits; seconds on a laptop, once, so made-up sites cost real time)\n`);
317
+ const t0 = Date.now();
318
+ for (let n = 0; ; n++) {
319
+ const nonce = String(n);
320
+ const got = leadingZeroBits(createHash('sha256').update(`${PROOF_PREFIX}${id}:${nonce}`).digest());
321
+ if (got >= bits) {
322
+ mkdirSync(path.dirname(file), { recursive: true });
323
+ writeFileSync(file, `${nonce} ${got} ${id}\n`, { mode: 0o600 });
324
+ process.stderr.write(`siteread: made it in ${((Date.now() - t0) / 1000).toFixed(1)} s, kept in ${file}\n`);
325
+ return { nonce, bits: got };
326
+ }
327
+ }
328
+ }
329
+
330
+ // Plain http only to this machine: a share crosses the internet over TLS or not at all.
331
+ function endpointOk(raw) {
332
+ let u;
333
+ try { u = new URL(raw); } catch { return false; }
334
+ if (u.protocol === 'https:') return true;
335
+ return u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
336
+ }
337
+
338
+ async function cmdShare({ flags, opts, pos }) {
339
+ if (pos.length > 2) return usage('share takes at most one digest file or folder');
340
+ const one = k => opts.get(k)?.at(-1) ?? null;
341
+ let file = pos[1] ?? path.join('.siteread', 'digests');
342
+ if (statSync(file).isDirectory()) {
343
+ const newest = readdirSync(file).filter(f => DIGEST_RE.test(f)).sort().at(-1);
344
+ if (!newest) return usage(`no digest (YYYY-MM-DD.json) in ${file}; run "siteread digest --out ${file}" first`);
345
+ file = path.join(file, newest);
346
+ }
347
+ let d;
348
+ try { d = JSON.parse(readFileSync(file, 'utf8')); } catch (err) { return usage(`${file}: ${err.message}`); }
349
+ const endpoint = one('--endpoint') ?? process.env.SITEREAD_SHARE_URL ?? SHARE_URL;
350
+ if (!endpointOk(endpoint)) return usage(`--endpoint must be an https:// URL (plain http only to localhost): ${endpoint}`);
351
+
352
+ let built;
353
+ const idFile = one('--id-file') ?? path.join('.siteread', 'share-id');
354
+ const proofFile = path.join(path.dirname(idFile), 'share-proof');
355
+ try {
356
+ const id = shareId(idFile);
357
+ // A known site never needs its proof again; a new one needs it at today's price, which the
358
+ // service names when it asks for more. SITEREAD_PROOF_BITS only sets where the first try starts.
359
+ const proof = readProof(proofFile, id) ?? makeProof(proofFile, id, Number(process.env.SITEREAD_PROOF_BITS) || PROOF_BASE_BITS);
360
+ built = sharePayload(d, { id, proof: proof.nonce });
361
+ } catch (err) {
362
+ if (err instanceof UsageError) throw err;
363
+ process.stderr.write(`siteread: share: ${err.message}\n`);
364
+ return 1;
365
+ }
366
+ // These exact bytes are what goes over the wire: print, then send the same string.
367
+ const body = JSON.stringify(built.payload, null, 2);
368
+ process.stderr.write(`siteread: the whole payload from ${file}, byte for byte, for ${endpoint}:\n`);
369
+ process.stdout.write(`${body}\n`);
370
+ for (const n of built.notes) process.stderr.write(`siteread: ${n}\n`);
371
+ if (flags.has('--dry-run')) { process.stderr.write('siteread: not sent (--dry-run)\n'); return 0; }
372
+ if (!flags.has('--yes')) {
373
+ if (!process.stdin.isTTY) { process.stderr.write('siteread: not sent: no terminal to ask on; pass --yes to send it\n'); return 1; }
374
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
375
+ const answer = await rl.question('Send it? [y/N] ');
376
+ rl.close();
377
+ if (!/^y(es)?$/i.test(answer.trim())) { process.stderr.write('siteread: not sent\n'); return 0; }
378
+ }
379
+
380
+ let res, r = null;
381
+ try {
382
+ res = await fetch(endpoint, {
383
+ method: 'POST', body, signal: AbortSignal.timeout(15_000),
384
+ headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': USER_AGENT },
385
+ });
386
+ try { r = JSON.parse(await res.text()); } catch { r = null; }
387
+ } catch (err) {
388
+ process.stderr.write(`siteread: could not reach ${endpoint}: ${err.cause?.code || err.message}\n`);
389
+ return 1;
390
+ }
391
+ if (!res.ok || r?.ok !== true) {
392
+ process.stderr.write(`siteread: ${endpoint} did not take it: HTTP ${res.status}${r?.error ? `: ${clean(r.error)}` : ''}\n`);
393
+ // A busy day for new sites: make the stronger proof now, and let the user send again (the
394
+ // payload changes, so it is printed again before anything goes out).
395
+ const need = Number(r?.need);
396
+ if (Number.isInteger(need) && need > 0 && need <= 40) {
397
+ makeProof(proofFile, built.payload.id, need);
398
+ process.stderr.write('siteread: run share again to send it with the new proof\n');
399
+ }
400
+ return 1;
401
+ }
402
+ // Where each number falls among the quartiles the benchmark publishes, and only where it
403
+ // publishes them: the service never answers finer than the public page (cloud/src/benchmark.js).
404
+ const a = r.against;
405
+ const min = Number(a?.minSites) || 10;
406
+ const rows = Object.entries(r.metrics ?? {}).filter(([, m]) => m && typeof m === 'object');
407
+ const where = m => BAND_TEXT[m.band] ?? `${Number(m.sites) || 0} site${Number(m.sites) === 1 ? '' : 's'}; shown from ${min}`;
408
+ const w = Math.max(6, ...rows.map(([k]) => k.length));
409
+ const ww = Math.max(5, ...rows.map(([, m]) => where(m).length));
410
+ const out = [
411
+ a
412
+ ? `sent ${clean(r.week)}. Against the benchmark built ${clean(a.generatedAt).slice(0, 16).replace('T', ' ')} UTC: ${Number(a.sites) || 0} anonymous site${Number(a.sites) === 1 ? '' : 's'}, ${clean(a.window?.from)} to ${clean(a.window?.to)}`
413
+ : `sent ${clean(r.week)}. The benchmark has not been built yet; it is rebuilt every hour.`,
414
+ '',
415
+ `${'metric'.padEnd(w)} ${'yours'.padStart(9)} ${'where'.padEnd(ww)}`,
416
+ ...rows.map(([k, m]) => `${clean(k).padEnd(w)} ${clean(m.value).padStart(9)} ${where(m).padEnd(ww)}`.trimEnd()
417
+ + (SHARE_METRICS[k]?.lowerIsBetter && m.band ? ' (lower is better)' : '')),
418
+ ];
419
+ if (!rows.some(([, m]) => m.band)) out.push('', `No number has ${min} anonymous sites yet, so none shows where you stand; your share counts toward them.`);
420
+ if (r.benchmark) out.push('', `benchmark: ${clean(r.benchmark)}`);
421
+ process.stdout.write(`${out.join('\n')}\n`);
422
+ return 0;
423
+ }
424
+
425
+ async function main(argv) {
426
+ let args;
427
+ try { args = parseArgs(argv); } catch (err) { if (err instanceof UsageError) return usage(err.message); throw err; }
428
+ const { flags, pos } = args;
429
+ if (flags.has('--help') || flags.has('-h') || pos[0] === 'help') { process.stdout.write(`${HELP}\n`); return 0; }
430
+ if (flags.has('--version') || flags.has('-v')) { process.stdout.write(`${VERSION}\n`); return 0; }
431
+ const known = new Set(['--no-cache', '--no-feed', '--json', '--offline', '--dry-run', '--yes']);
432
+ const bad = [...flags].find(f => !known.has(f));
433
+ if (bad) return usage(`unknown option ${bad}`);
434
+ if (pos[0] === 'check') return cmdCheck(args);
435
+ if (pos[0] === 'lists') return cmdLists(args);
436
+ if (pos[0] === 'digest') {
437
+ try { return await cmdDigest(args); } catch (err) {
438
+ if (err instanceof UsageError) return usage(err.message);
439
+ if (err?.code === 'ENOENT') return usage(`no such file: ${err.path}`);
440
+ // Bad input (an export with no Dates.csv, a broken zip): say what, no stack trace.
441
+ process.stderr.write(`siteread: digest: ${err?.message || err}\n`);
442
+ return 1;
443
+ }
444
+ }
445
+ if (pos[0] === 'share') {
446
+ try { return await cmdShare(args); } catch (err) {
447
+ if (err instanceof UsageError) return usage(err.message);
448
+ if (err?.code === 'ENOENT') return usage(`no such file: ${err.path}`);
449
+ process.stderr.write(`siteread: share: ${err?.message || err}\n`);
450
+ return 1;
451
+ }
452
+ }
453
+ if (!pos.length) { process.stdout.write(`${HELP}\n`); return EXIT.usage; }
454
+ return usage(`unknown command "${pos[0]}"`);
455
+ }
456
+
457
+ main(process.argv.slice(2)).then(
458
+ (code) => { process.exitCode = code; },
459
+ (err) => { process.stderr.write(`siteread: ${err?.stack || err}\n`); process.exitCode = 1; },
460
+ );
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.1",
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 test/*.test.js"
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
+ }
package/src/agents.js ADDED
@@ -0,0 +1,82 @@
1
+ // Who a request CLAIMS to be, from its User-Agent. A claim only: anyone can send any UA, and
2
+ // on one site's log most AI user-agents outside the vendors' IP lists were a scanner cycling
3
+ // through them (ip-ranges.js has the numbers). verifier.js turns a claim into a verdict.
4
+ //
5
+ // purpose: `answer` fetched live while answering someone (ChatGPT-User, Perplexity-User)
6
+ // `search` building the assistant's search index (OAI-SearchBot, PerplexityBot)
7
+ // `training` a model-training crawl (GPTBot, ClaudeBot, CCBot)
8
+
9
+ // Ordered: the FIRST match on the User-Agent wins. Not AI (left out on purpose, see CRAWLERS):
10
+ // PetalBot, GoogleOther, Googlebot, bingbot. Copilot's fetches cannot be told apart from Bing's.
11
+ export const AGENTS = Object.freeze([
12
+ { key: 'chatgpt-user', re: /ChatGPT-User/i, assistant: 'chatgpt', purpose: 'answer' },
13
+ { key: 'oai-searchbot', re: /OAI-SearchBot/i, assistant: 'chatgpt', purpose: 'search' },
14
+ { key: 'gptbot', re: /GPTBot/i, assistant: 'chatgpt', purpose: 'training' },
15
+ // Before claude-user: Claude Code's WebFetch sends `Claude-User (claude-code/2.1.292; …)` from
16
+ // the user's OWN machine, so it can never be in Anthropic's IP list and stays on the 444 rule.
17
+ { key: 'claude-code', re: /^claude-code\/|^Claude-User \(claude-code\//i, assistant: 'claude', purpose: 'answer',
18
+ note: "Claude Code's WebFetch runs from the user's own machine, so no vendor list can cover it" },
19
+ { key: 'claude-user', re: /Claude-User/i, assistant: 'claude', purpose: 'answer' },
20
+ { key: 'claude-searchbot', re: /Claude-SearchBot/i, assistant: 'claude', purpose: 'search' },
21
+ { key: 'claudebot', re: /ClaudeBot/i, assistant: 'claude', purpose: 'training' },
22
+ { key: 'perplexity-user', re: /Perplexity-User/i, assistant: 'perplexity', purpose: 'answer' },
23
+ { key: 'perplexitybot', re: /PerplexityBot/i, assistant: 'perplexity', purpose: 'search' },
24
+ { key: 'applebot', re: /Applebot/i, assistant: 'apple', purpose: 'search' },
25
+ { key: 'amazonbot', re: /Amazonbot/i, assistant: 'amazon', purpose: 'search' },
26
+ { key: 'meta-externalfetcher', re: /meta-externalfetcher/i, assistant: 'meta', purpose: 'answer' },
27
+ { key: 'meta-externalagent', re: /meta-externalagent/i, assistant: 'meta', purpose: 'training' },
28
+ { key: 'duckassistbot', re: /DuckAssistBot/i, assistant: 'duckduckgo', purpose: 'answer' },
29
+ { key: 'mistralai-user', re: /MistralAI-User/i, assistant: 'mistral', purpose: 'answer' },
30
+ { key: 'youbot', re: /YouBot/i, assistant: 'you', purpose: 'search' },
31
+ // Not a real User-Agent (Google-Extended is a robots.txt token): always spoofed in practice.
32
+ // Google's crawlers obey it, but fetch as Googlebot; nothing real ever sends it as a UA.
33
+ { key: 'google-extended', re: /Google-Extended/i, assistant: 'gemini', purpose: 'training',
34
+ robotsTokenOnly: true, note: 'Google-Extended is a robots.txt token, never a real user-agent' },
35
+ { key: 'ccbot', re: /CCBot/i, assistant: 'commoncrawl', purpose: 'training' },
36
+ { key: 'bytespider', re: /Bytespider/i, assistant: 'bytedance', purpose: 'training' },
37
+ { key: 'cohere', re: /cohere-ai/i, assistant: 'cohere', purpose: 'training' },
38
+ { key: 'deepseek', re: /DeepSeekBot/i, assistant: 'deepseek', purpose: 'training' },
39
+ { key: 'xai', re: /xAI-Grok|GrokBot/i, assistant: 'grok', purpose: 'training' },
40
+ { key: 'ai2bot', re: /AI2Bot/i, assistant: 'ai2', purpose: 'training' },
41
+ ].map(Object.freeze));
42
+
43
+ // Classic search-engine and SEO-tool crawlers, the non-AI half of the original CRAWLER_RE. The
44
+ // AI names it also matched (GPTBot, OAI-SearchBot, ChatGPT, ClaudeBot, Applebot, Amazonbot,
45
+ // Bytespider) live in AGENTS, which is checked first. purpose: `search` (an engine's index),
46
+ // `seo` (a backlink/SEO tool's crawl) or `preview` (a link-preview fetch).
47
+ export const CRAWLERS = Object.freeze([
48
+ { key: 'googlebot', re: /Googlebot/i, operator: 'google', purpose: 'search' },
49
+ { key: 'bingbot', re: /bingbot/i, operator: 'bing', purpose: 'search' },
50
+ { key: 'yandexbot', re: /YandexBot/i, operator: 'yandex', purpose: 'search' },
51
+ { key: 'duckduckbot', re: /DuckDuckBot/i, operator: 'duckduckgo', purpose: 'search' },
52
+ { key: 'slurp', re: /Slurp/i, operator: 'yahoo', purpose: 'search' },
53
+ { key: 'petalbot', re: /PetalBot/i, operator: 'huawei', purpose: 'search' },
54
+ { key: 'ahrefsbot', re: /AhrefsBot/i, operator: 'ahrefs', purpose: 'seo' },
55
+ { key: 'semrushbot', re: /SemrushBot/i, operator: 'semrush', purpose: 'seo' },
56
+ { key: 'mj12bot', re: /MJ12bot/i, operator: 'majestic', purpose: 'seo' },
57
+ { key: 'facebookexternalhit', re: /facebookexternalhit/i, operator: 'meta', purpose: 'preview' },
58
+ ].map(Object.freeze));
59
+
60
+ // One cheap test before the ordered walk: most lines are not an AI agent at all.
61
+ const ANY_AGENT_RE = new RegExp(AGENTS.map(a => a.re.source).join('|'), 'i');
62
+ const ANY_CRAWLER_RE = new RegExp(CRAWLERS.map(c => c.re.source).join('|'), 'i');
63
+
64
+ /** The AGENTS entry a User-Agent claims to be, or null. */
65
+ export function detectAgent(ua) {
66
+ if (typeof ua !== 'string' || !ua || !ANY_AGENT_RE.test(ua)) return null;
67
+ for (const a of AGENTS) if (a.re.test(ua)) return a;
68
+ return null;
69
+ }
70
+
71
+ /** The CRAWLERS entry a User-Agent claims to be, or null. Not an AI agent: those match AGENTS. */
72
+ export function detectCrawler(ua) {
73
+ if (typeof ua !== 'string' || !ua || !ANY_CRAWLER_RE.test(ua)) return null;
74
+ if (detectAgent(ua)) return null;
75
+ for (const c of CRAWLERS) if (c.re.test(ua)) return c;
76
+ return null;
77
+ }
78
+
79
+ /** True when the UA claims to be a classic search-engine or SEO crawler (never an AI agent). */
80
+ export function isSearchCrawler(ua) {
81
+ return detectCrawler(ua) !== null;
82
+ }
@@ -0,0 +1,37 @@
1
+ // RFC 4180 CSV, enough for a Search Console export: quoted fields, "" escapes, CRLF or LF,
2
+ // a UTF-8 BOM. No dependencies.
3
+
4
+ /** Rows of a CSV text as arrays of strings. Blank lines are dropped. */
5
+ export function parseCsv(text) {
6
+ const s = String(text ?? '').replace(/^/, '');
7
+ const rows = [];
8
+ let row = [];
9
+ let field = '';
10
+ let quoted = false;
11
+ for (let i = 0; i < s.length; i++) {
12
+ const c = s[i];
13
+ if (quoted) {
14
+ if (c === '"') {
15
+ if (s[i + 1] === '"') { field += '"'; i++; } else quoted = false;
16
+ } else field += c;
17
+ } else if (c === '"') quoted = true;
18
+ else if (c === ',') { row.push(field); field = ''; }
19
+ else if (c === '\n' || c === '\r') {
20
+ if (c === '\r' && s[i + 1] === '\n') i++;
21
+ row.push(field); field = '';
22
+ if (row.some(f => f !== '')) rows.push(row);
23
+ row = [];
24
+ } else field += c;
25
+ }
26
+ row.push(field);
27
+ if (row.some(f => f !== '')) rows.push(row);
28
+ return rows;
29
+ }
30
+
31
+ /** The CSV as objects keyed by its header row. */
32
+ export function parseCsvObjects(text) {
33
+ const [head, ...rows] = parseCsv(text);
34
+ if (!head) return { header: [], rows: [] };
35
+ const header = head.map(h => h.trim());
36
+ return { header, rows: rows.map(r => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? '').trim()]))) };
37
+ }