siteread 0.2.0 → 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/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  **Was that really ClaudeBot?** `siteread` takes the IP and User-Agent of a request from your access
4
4
  log and checks the IP against the list the claimed vendor publishes: `verified`, `spoofed` or `unknown`.
5
5
 
6
- Node ≥ 20, zero dependencies, nothing leaves your machine except GETs of the vendors' public IP lists.
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).
7
8
 
8
9
  ## Install
9
10
 
@@ -14,8 +15,10 @@ npx siteread --help # the CLI, no install
14
15
 
15
16
  ## CLI
16
17
 
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.
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.
19
22
 
20
23
  ```console
21
24
  $ npx siteread check 216.73.216.12 "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)"
@@ -60,7 +63,7 @@ Options: `--no-cache` refetches every list now (a failed fetch still falls back
60
63
  ## Digest (for the `seo-read` skill)
61
64
 
62
65
  `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
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
64
67
  impression-weighted position, people by first-touch source (an IP that sent a browser UA, loaded
65
68
  an asset and never probed a scanner path), goal counts, and every crawler checked against its
66
69
  vendor's list.
@@ -71,8 +74,86 @@ $ npx siteread digest --gsc export.zip --log access.log.1.gz --log access.log \
71
74
  .siteread/digests/2026-10-18.json
72
75
  ```
73
76
 
74
- `npx siteread --help` lists every option. The skill in [`skill/`](../../skill/README.md) turns
75
- the JSON into a weekly memo.
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.
76
157
 
77
158
  ## Library
78
159
 
@@ -98,7 +179,8 @@ verifier.check('216.73.216.12', 'Mozilla/5.0 … ClaudeBot/1.0; +claudebot@anthr
98
179
  | `now` | `Date.now` | a clock, for tests |
99
180
  | `log` | `console` | `{ warn, error }`; pass `{}` for silence |
100
181
  | `isKnownScannerIp` | none | `(ip) => boolean`, the 444 rule (below) |
101
- | `feedUrl` | none | one merged document instead of the vendor URLs (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 |
102
184
 
103
185
  It returns:
104
186
 
@@ -218,16 +300,22 @@ All of them answer `{ "creationTime": …, "prefixes": [{ "ipv4Prefix": … } |
218
300
 
219
301
  ## Privacy
220
302
 
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.
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.
224
310
 
225
311
  `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.
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.
230
318
 
231
319
  ## License
232
320
 
233
- MIT. Extracted from the pipeline Synacl Labs runs on synacl.com.
321
+ MIT.
package/bin/siteread.js CHANGED
@@ -4,15 +4,21 @@
4
4
  // siteread check <ip> "<user-agent>" exit 0 verified · 2 spoofed · 3 unknown / n/a · 1 usage or error
5
5
  // siteread lists
6
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
7
8
  // siteread --help
8
9
  //
9
10
  // 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
+ // 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.
11
14
 
12
15
  import os from 'node:os';
13
16
  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';
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';
16
22
 
17
23
  const EXIT = { verified: 0, spoofed: 2, unknown: 3, 'n/a': 3, usage: 1 };
18
24
  const cacheFile = process.env.SITEREAD_CACHE || path.join(os.tmpdir(), 'siteread', 'lists.json');
@@ -24,16 +30,24 @@ Usage:
24
30
  siteread lists every vendor list: prefixes and when it was fetched
25
31
  siteread digest [options] a Search Console export + access log -> JSON numbers
26
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)
27
36
  siteread --help | --version
28
37
 
29
38
  Options:
30
39
  --no-cache refetch every list now (a copy younger than 24 h is otherwise reused;
31
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)
32
43
  --json print the full result as JSON
33
44
 
34
45
  Digest options (at least one of --gsc / --log):
35
46
  --gsc <zip|dir> Search Console Performance export (Export -> Download CSV)
36
- --log <file> access log, nginx/Apache combined, .gz ok (repeat for several)
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)
37
51
  --site <host> your site's host (guessed from Referers otherwise)
38
52
  --sitemap <file> a local sitemap.xml: count crawler fetches on its URLs only
39
53
  --from / --to <day> keep only log days in this range (YYYY-MM-DD)
@@ -50,6 +64,13 @@ Digest options (at least one of --gsc / --log):
50
64
  --limit <n> rows per table (default 25)
51
65
  --offline use cached vendor lists only; no network at all
52
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
+
53
74
  Exit codes (check):
54
75
  0 verified the IP is in the vendor's published list for that agent
55
76
  2 spoofed the vendor publishes a list and the IP is not in it
@@ -57,11 +78,12 @@ Exit codes (check):
57
78
  1 usage error, or not an IP address
58
79
 
59
80
  Cache: ${cacheFile} (override with SITEREAD_CACHE)
60
- Nothing leaves this machine except GETs of the vendors' public IP lists.`;
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.`;
61
83
 
62
84
  // Options that take a value (digest only). Each keeps every value given, in order.
63
85
  const VALUE_OPTS = new Set(['--gsc', '--log', '--out', '--site', '--sitemap', '--from', '--to', '--split', '--goal',
64
- '--team-path', '--exclude-ip', '--allow-path', '--tz', '--prev', '--limit']);
86
+ '--team-path', '--exclude-ip', '--allow-path', '--tz', '--prev', '--limit', '--endpoint', '--id-file', '--host']);
65
87
 
66
88
  function parseArgs(argv) {
67
89
  const flags = new Set();
@@ -137,10 +159,18 @@ function describe(ip, ua, r, list) {
137
159
  }
138
160
  }
139
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
+
140
170
  async function cmdCheck({ flags, pos }) {
141
171
  if (pos.length !== 3) return usage('check takes exactly two arguments: <ip> "<user-agent>" (quote the user-agent)');
142
172
  const [, ip, ua] = pos;
143
- const verifier = createVerifier({ store: fileStore(cacheFile), log: {} });
173
+ const verifier = newVerifier(flags);
144
174
  let r = verifier.check(ip, ua);
145
175
  if (r.reason === 'invalid-ip') return usage(`"${ip}" is not an IP address`);
146
176
  // Only touch the network when the claimed agent has a list to check against.
@@ -160,7 +190,7 @@ async function cmdCheck({ flags, pos }) {
160
190
 
161
191
  async function cmdLists({ flags, pos }) {
162
192
  if (pos.length !== 1) return usage('lists takes no arguments');
163
- const verifier = createVerifier({ store: fileStore(cacheFile), log: {} });
193
+ const verifier = newVerifier(flags);
164
194
  const result = await verifier.refresh({ force: flags.has('--no-cache') });
165
195
  reportFailures(result, verifier);
166
196
  const rows = verifier.lists();
@@ -203,7 +233,7 @@ async function cmdDigest({ flags, opts, pos }) {
203
233
 
204
234
  let verifier = null;
205
235
  if (logs.length) {
206
- verifier = createVerifier({ store: fileStore(cacheFile), log: {} });
236
+ verifier = newVerifier(flags);
207
237
  if (flags.has('--offline')) {
208
238
  await verifier.load();
209
239
  } else {
@@ -220,6 +250,7 @@ async function cmdDigest({ flags, opts, pos }) {
220
250
  site: one('--site'), tz, from: one('--from'), to: one('--to'), goals,
221
251
  teamPath: re('--team-path'), allowPath: re('--allow-path'),
222
252
  excludeIps: new Set((opts.get('--exclude-ip') ?? []).flatMap(s => s.split(',')).map(s => s.trim()).filter(Boolean)),
253
+ hosts: opts.get('--host') ?? [],
223
254
  });
224
255
  if (verifier) out.vendorLists = verifier.lists().map(({ agent, prefixes, fetchedAt }) => ({ agent, prefixes, fetchedAt }));
225
256
  for (const w of out.warnings) process.stderr.write(`siteread: ${w}\n`);
@@ -247,13 +278,157 @@ async function cmdDigest({ flags, opts, pos }) {
247
278
  return 0;
248
279
  }
249
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
+
250
425
  async function main(argv) {
251
426
  let args;
252
427
  try { args = parseArgs(argv); } catch (err) { if (err instanceof UsageError) return usage(err.message); throw err; }
253
428
  const { flags, pos } = args;
254
429
  if (flags.has('--help') || flags.has('-h') || pos[0] === 'help') { process.stdout.write(`${HELP}\n`); return 0; }
255
430
  if (flags.has('--version') || flags.has('-v')) { process.stdout.write(`${VERSION}\n`); return 0; }
256
- const known = new Set(['--no-cache', '--json', '--offline']);
431
+ const known = new Set(['--no-cache', '--no-feed', '--json', '--offline', '--dry-run', '--yes']);
257
432
  const bad = [...flags].find(f => !known.has(f));
258
433
  if (bad) return usage(`unknown option ${bad}`);
259
434
  if (pos[0] === 'check') return cmdCheck(args);
@@ -267,6 +442,14 @@ async function main(argv) {
267
442
  return 1;
268
443
  }
269
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
+ }
270
453
  if (!pos.length) { process.stdout.write(`${HELP}\n`); return EXIT.usage; }
271
454
  return usage(`unknown command "${pos[0]}"`);
272
455
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siteread",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
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
5
  "type": "module",
6
6
  "engines": {
@@ -19,7 +19,7 @@
19
19
  "README.md"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --test"
22
+ "test": "node --test test/*.test.js"
23
23
  },
24
24
  "keywords": [
25
25
  "crawler",
package/src/agents.js CHANGED
@@ -1,9 +1,6 @@
1
1
  // Who a request CLAIMS to be, from its User-Agent. A claim only: anyone can send any UA, and
2
- // on the site this was extracted from most AI user-agents outside the vendors' IP lists were a
3
- // scanner cycling through them (ip-ranges.js has the numbers). verifier.js turns a claim into a
4
- // verdict.
5
- //
6
- // Ported from the AI-traffic classifier Synacl Labs runs on synacl.com (2026-10).
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.
7
4
  //
8
5
  // purpose: `answer` fetched live while answering someone (ChatGPT-User, Perplexity-User)
9
6
  // `search` building the assistant's search index (OAI-SearchBot, PerplexityBot)
package/src/digest/gsc.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // A Google Search Console Performance export (the "Export → Download CSV" zip, or the folder it
2
2
  // unzips to) → the numbers a weekly read needs.
3
3
  //
4
- // The method is the one that held up over six weekly reads of synacl.com (2026-09 → 10):
4
+ // The method is the one that held up over six weekly reads of one site (2026-09 → 10):
5
5
  // - Totals come from the daily series (Dates.csv; older exports call it Chart.csv), never by
6
6
  // summing Pages.csv: a page table double-counts an impression that showed two URLs.
7
7
  // - Position is impression-weighted (Σ position·impressions / Σ impressions). A plain mean
@@ -10,7 +10,7 @@ import { VERSION } from '../version.js';
10
10
 
11
11
  // GSC clicks vs people the log saw arrive from Google, over the days both cover. They are
12
12
  // different counts (a click without a Referer reads as direct; one person can click twice), so
13
- // this is a sanity check, never a sum. On synacl.com they tracked within 10 % day by day.
13
+ // this is a sanity check, never a sum. On one site they tracked within 10 % day by day.
14
14
  function crossCheck(gsc, log) {
15
15
  const from = gsc.range.from > log.range.from ? gsc.range.from : log.range.from;
16
16
  const to = gsc.range.to < log.range.to ? gsc.range.to : log.range.to;
@@ -61,8 +61,13 @@ export async function digest({ gsc = null, logs = [], verifier = null, prev = nu
61
61
  for (const f of logs) await readLog(f, d);
62
62
  out.log = d.finish(verifier);
63
63
  if (out.log.lines.unparsed > out.log.lines.total / 2) {
64
- out.warnings.push(`log: ${out.log.lines.unparsed} of ${out.log.lines.total} lines are not in combined format; counts are from the rest`);
64
+ out.warnings.push(`log: ${out.log.lines.unparsed} of ${out.log.lines.total} lines are in no format siteread reads (combined, Caddy JSON, nginx JSON); counts are from the rest`);
65
65
  }
66
+ const { total, unparsed, otherHost, noHost } = out.log.lines;
67
+ if (logOpts.hosts?.length && otherHost && otherHost === total - unparsed) {
68
+ out.warnings.push(`log: no request was to ${logOpts.hosts.join(' or ')}; every line was for another host`);
69
+ }
70
+ if (noHost) out.warnings.push(`log: ${noHost} lines carry no host, so --host could not filter them; they are counted`);
66
71
  if (out.log.site.guessed) out.warnings.push(`log: site host guessed as ${out.log.site.host ?? 'unknown'} from Referers; pass --site to set it`);
67
72
  if (out.gsc && out.log.range.from) {
68
73
  const a = out.gsc.range, b = out.log.range;
package/src/digest/log.js CHANGED
@@ -1,6 +1,7 @@
1
- // An access log (nginx / Apache "combined", plain or .gz) → people, sources and crawlers.
1
+ // An access log (nginx / Apache "combined", Caddy's JSON or an nginx JSON log_format; plain or .gz)
2
+ // → people, sources and crawlers. A file that holds several sites is cut to one with `hosts`.
2
3
  //
3
- // Who counts as a person is the rule synacl.com settled on after its first counts were wrong
4
+ // Who counts as a person is the rule one site settled on after its first counts were wrong
4
5
  // (2026-09-28, stricter than the read a week earlier, so compare only reads made the same way):
5
6
  // a person = an IP that sent no bot User-Agent, never asked for a scanner path or drew a 444,
6
7
  // and loaded at least one asset (CSS/JS/font/image). Link checkers, previewers and most bots
@@ -11,7 +12,7 @@
11
12
  // Referer from a cloud provider's address space is set aside and counted on its own line.
12
13
  //
13
14
  // Crawlers are counted on page fetches (and on sitemap URLs when a sitemap is given), each IP
14
- // checked against its vendor's list. The raw UA count lies: one week synacl.com's log showed
15
+ // checked against its vendor's list. The raw UA count lies: one week that site's log showed
15
16
  // "Googlebot 6.9k", and it was scanners wearing Googlebot's UA against the bare IP. Googlebot
16
17
  // also crawls from 192.178.x, not only 66.249.x: a first-octet guess undercounted it by a third.
17
18
 
@@ -39,19 +40,108 @@ const GENERIC_BOT_RE = /bot\b|bot\/|crawl|spider|slurp|fetch|scan|curl\/|wget\/|
39
40
  export const CLOUD_FIRST_OCTETS = Object.freeze([3, 13, 15, 16, 18, 34, 35, 44, 52, 54]);
40
41
  const CLOUD = new Set(CLOUD_FIRST_OCTETS);
41
42
 
42
- /** One combined-format line → request, or null. */
43
+ /** "05/Oct/2026:20:00:00 +0200" (nginx $time_local, Apache %t) → ms, or NaN. */
44
+ function clfTime(s) {
45
+ const t = /^(\d{2})\/(\w{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec(s);
46
+ if (!t || !(t[2] in MONTHS)) return NaN;
47
+ const off = (t[7] === '-' ? -1 : 1) * (Number(t[8]) * 60 + Number(t[9]));
48
+ return Date.UTC(+t[3], MONTHS[t[2]], +t[1], +t[4], +t[5], +t[6]) - off * 60_000;
49
+ }
50
+
51
+ /**
52
+ * One access-log line → { ip, ts, method, target, status, referer, ua, host }, or null. Combined
53
+ * format, or a JSON object per line: Caddy's (it has a `request` object) or an nginx log_format.
54
+ * `host` is null when the format does not carry one (combined).
55
+ */
43
56
  export function parseLine(line) {
57
+ if (line.charCodeAt(0) === 123) return parseJsonLine(line);
44
58
  const m = LINE_RE.exec(line);
45
59
  if (!m) return null;
46
60
  const [, ip, time, method, target, status, referer = '-', ua = '-'] = m;
47
- const t = /^(\d{2})\/(\w{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec(time);
48
- if (!t || !(t[2] in MONTHS)) return null;
49
- const off = (t[7] === '-' ? -1 : 1) * (Number(t[8]) * 60 + Number(t[9]));
50
- const ts = Date.UTC(+t[3], MONTHS[t[2]], +t[1], +t[4], +t[5], +t[6]) - off * 60_000;
61
+ const ts = clfTime(time);
62
+ if (!Number.isFinite(ts)) return null;
51
63
  const unesc = s => s.replace(/\\x22/g, '"').replace(/\\"/g, '"');
52
- return { ip, ts, method, target, status: Number(status), referer: unesc(referer), ua: unesc(ua) };
64
+ return { ip, ts, method, target, status: Number(status), referer: unesc(referer), ua: unesc(ua), host: null };
65
+ }
66
+
67
+ // nginx writes JSON by hand from a log_format. With `escape=json` it is valid JSON; with the
68
+ // default escaping a quote or a non-ASCII byte comes out as \xHH, which JSON does not allow, so a
69
+ // line that fails is read again with those as \u00HH.
70
+ function parseJsonObject(line) {
71
+ try { return JSON.parse(line); } catch { /* try the nginx default escaping */ }
72
+ if (!line.includes('\\x')) return null;
73
+ try { return JSON.parse(line.replace(/\\x([0-9a-fA-F]{2})/g, '\\u00$1')); } catch { return null; }
74
+ }
75
+
76
+ function parseJsonLine(line) {
77
+ const e = parseJsonObject(line);
78
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return null;
79
+ return e.request && typeof e.request === 'object' ? caddyEntry(e) : nginxEntry(e);
53
80
  }
54
81
 
82
+ // Caddy writes its access log as JSON (the `log` directive's default) and cannot write "combined"
83
+ // without a plugin a distribution's package does not carry; siteread.dev's own log is one. Caddy
84
+ // has no 444, so a scanner there shows by its path alone, which the 444 rule reads anyway.
85
+ // `client_ip` (Caddy ≥ 2.7, after trusted proxies) wins over `remote_ip`; `ts` is Unix seconds
86
+ // unless the log was configured with another time format.
87
+ function caddyEntry(e) {
88
+ const r = e.request;
89
+ const ip = r.client_ip || r.remote_ip;
90
+ const ts = typeof e.ts === 'number' ? Math.round(e.ts * 1000) : Date.parse(e.ts);
91
+ if (typeof ip !== 'string' || !ip || !Number.isFinite(ts) || typeof r.method !== 'string' || typeof r.uri !== 'string'
92
+ || !Number.isInteger(e.status)) return null;
93
+ const first = k => (Array.isArray(r.headers?.[k]) && typeof r.headers[k][0] === 'string' ? r.headers[k][0] : '-');
94
+ return { ip, ts, method: r.method, target: r.uri, status: e.status, referer: first('Referer'), ua: first('User-Agent'), host: str(r.host) };
95
+ }
96
+
97
+ // An nginx JSON log_format names its fields as its author chose. These are the names in common use
98
+ // for each, first match wins: the variable's own name (remote_addr, http_user_agent) or a short one
99
+ // (ip, user_agent). The target is the raw $request_uri where there is one, since a scanner path
100
+ // is read undecoded; $uri is decoded, so it is the fallback, with $args put back.
101
+ const NGINX = {
102
+ ip: ['remote_addr', 'client_ip', 'remote_ip', 'ip', 'client', 'clientip'],
103
+ time: ['time', 'time_iso8601', 'timestamp', '@timestamp', 'time_local', 'msec', 'ts'],
104
+ method: ['method', 'request_method'],
105
+ target: ['request_uri'],
106
+ uri: ['uri', 'path'],
107
+ args: ['args', 'query_string'],
108
+ status: ['status', 'status_code', 'response'],
109
+ referer: ['referer', 'http_referer', 'referrer', 'http_referrer'],
110
+ ua: ['user_agent', 'http_user_agent', 'agent', 'useragent', 'ua'],
111
+ host: ['host', 'http_host', 'server_name', 'vhost', 'hostname'],
112
+ };
113
+ const str = v => (typeof v === 'string' && v !== '' ? v : null);
114
+ const pick = (e, keys) => { for (const k of keys) { const v = e[k]; if (v != null && v !== '') return v; } return null; };
115
+
116
+ function nginxEntry(e) {
117
+ const ip = str(pick(e, NGINX.ip));
118
+ if (!ip) return null;
119
+ let method = str(pick(e, NGINX.method));
120
+ let target = str(pick(e, NGINX.target));
121
+ if (!target) {
122
+ const uri = str(pick(e, NGINX.uri));
123
+ const args = str(pick(e, NGINX.args));
124
+ if (uri) target = args && args !== '-' ? `${uri}?${args}` : uri;
125
+ }
126
+ // Or the request line itself, "GET /path?q HTTP/1.1" ($request).
127
+ const line = typeof e.request === 'string' ? /^(\S+) (\S+)/.exec(e.request) : null;
128
+ if (line) { method ??= line[1]; target ??= line[2]; }
129
+ const t = pick(e, NGINX.time);
130
+ // $msec is seconds with a fraction; a number that large is already milliseconds.
131
+ const ts = typeof t === 'number' ? Math.round(t < 1e11 ? t * 1000 : t)
132
+ : typeof t === 'string' ? (/^\d+(\.\d+)?$/.test(t) ? Math.round(Number(t) * 1000) : Date.parse(t) || clfTime(t)) : NaN;
133
+ const rawStatus = pick(e, NGINX.status);
134
+ const status = rawStatus == null ? NaN : Number(rawStatus); // Number(null) would be a status 0
135
+ if (!method || !target || !Number.isFinite(ts) || !Number.isInteger(status)) return null;
136
+ return {
137
+ ip, ts, method, target, status,
138
+ referer: str(pick(e, NGINX.referer)) ?? '-', ua: str(pick(e, NGINX.ua)) ?? '-', host: str(pick(e, NGINX.host)),
139
+ };
140
+ }
141
+
142
+ /** A host as a filter compares it: lower case, no port, no trailing dot. */
143
+ export const normHost = h => String(h).trim().toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '');
144
+
55
145
  /** Day (YYYY-MM-DD) of a timestamp in an IANA zone. */
56
146
  export function dayIn(tz) {
57
147
  const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' });
@@ -76,18 +166,21 @@ function top(map, limit) {
76
166
  * @param {RegExp} [o.teamPath] an IP that got a 2xx on such a path is the site's own team
77
167
  * @param {Set<string>} [o.excludeIps]
78
168
  * @param {RegExp} [o.allowPath] a site's own paths that look like scanner probes
169
+ * @param {string[]} [o.hosts] keep only requests to these hosts (a log of several sites);
170
+ * a line whose format carries no host is kept and counted
79
171
  */
80
172
  export function createLogDigest({
81
173
  site = null, tz = 'America/Los_Angeles', from = null, to = null, sitemap = null,
82
- goals = [], teamPath = null, excludeIps = new Set(), allowPath = null, limit = 15,
174
+ goals = [], teamPath = null, excludeIps = new Set(), allowPath = null, limit = 15, hosts = [],
83
175
  } = {}) {
176
+ const keepHosts = hosts.length ? new Set(hosts.map(normHost)) : null;
84
177
  const day = dayIn(tz);
85
178
  const ips = new Map(); // ip → per-IP facts
86
179
  const crawl = new Map(); // `${agent}\t${ip}` → { agent, ip, ua, requests, pages: Map(day → [path]) }
87
180
  const generic = { requests: 0, ips: new Set(), uas: new Map() };
88
181
  const scannerPaths = new Map();
89
182
  const refHosts = new Map();
90
- let lines = 0, unparsed = 0, outOfRange = 0, firstDay = null, lastDay = null;
183
+ let lines = 0, unparsed = 0, outOfRange = 0, otherHost = 0, noHost = 0, firstDay = null, lastDay = null;
91
184
 
92
185
  function ipRec(ip) {
93
186
  let r = ips.get(ip);
@@ -102,6 +195,10 @@ export function createLogDigest({
102
195
  lines++;
103
196
  const q = parseLine(line);
104
197
  if (!q) { unparsed++; return; }
198
+ if (keepHosts) {
199
+ if (q.host == null) noHost++;
200
+ else if (!keepHosts.has(normHost(q.host))) { otherHost++; return; }
201
+ }
105
202
  const d = day(q.ts);
106
203
  if ((from && d < from) || (to && d > to)) { outOfRange++; return; }
107
204
  if (!firstDay || d < firstDay) firstDay = d;
@@ -150,8 +247,9 @@ export function createLogDigest({
150
247
  }
151
248
 
152
249
  function finish(verifier) {
153
- // The site's own host: given, or the Referer host browsers' asset loads carry most.
154
- const siteHost = site ? site.replace(/^www\./, '') : (top(refHosts, 1)[0]?.[0] ?? null);
250
+ // The site's own host: given, the first --host, or the Referer host asset loads carry most.
251
+ const given = site ?? hosts[0] ?? null;
252
+ const siteHost = given ? normHost(given).replace(/^www\./, '') : (top(refHosts, 1)[0]?.[0] ?? null);
155
253
 
156
254
  // People.
157
255
  const people = [];
@@ -252,7 +350,7 @@ export function createLogDigest({
252
350
  a = {
253
351
  agent: c.agent, kind: detectAgent(c.ua) ? 'ai' : 'crawler', purpose: e?.purpose ?? null,
254
352
  requests: 0, ips: 0,
255
- verified: { ips: 0, requests: 0, pageFetches: 0, sitemapFetches: 0, pages: new Set(), perDay: new Map() },
353
+ verified: { ips: 0, requests: 0, pageFetches: 0, sitemapFetches: 0, pages: new Set(), perDay: new Map(), perDayAll: new Map() },
256
354
  spoofed: { ips: 0, requests: 0 },
257
355
  unknown: { ips: 0, requests: 0, pageFetches: 0 },
258
356
  };
@@ -267,6 +365,7 @@ export function createLogDigest({
267
365
  for (const [d, ps] of c.pages) {
268
366
  const counted = sitemap ? ps.filter(p => sitemap.has(p)) : ps;
269
367
  countBy(b.perDay, d, counted.length);
368
+ countBy(b.perDayAll, d, ps.length);
270
369
  b.sitemapFetches += sitemap ? counted.length : 0;
271
370
  for (const p of ps) b.pages.add(p);
272
371
  }
@@ -289,7 +388,7 @@ export function createLogDigest({
289
388
  sitemapFetches: sitemap ? v.sitemapFetches : null,
290
389
  uniquePages: v.pages.size, sitemapCovered: covered,
291
390
  offSitemapPages: offSitemap ? offSitemap.slice(0, 10) : null,
292
- perWeek: weekly(v.perDay, lastDay, firstDay),
391
+ perWeek: weekly(v.perDay, lastDay, firstDay, v.perDayAll),
293
392
  },
294
393
  spoofed: a.spoofed,
295
394
  unknown: a.unknown,
@@ -301,8 +400,8 @@ export function createLogDigest({
301
400
 
302
401
  return {
303
402
  range: { from: firstDay, to: lastDay, tz },
304
- site: { host: siteHost, guessed: !site },
305
- lines: { total: lines, unparsed, outOfRange },
403
+ site: { host: siteHost, guessed: !given },
404
+ lines: { total: lines, unparsed, outOfRange, otherHost, noHost },
306
405
  people: {
307
406
  total: people.length,
308
407
  perDay: dayList,
@@ -323,7 +422,7 @@ export function createLogDigest({
323
422
  return { add, finish };
324
423
  }
325
424
 
326
- // Sitemap URLs no verified search crawler fetched in this log. On synacl.com one clean guide was
425
+ // Sitemap URLs no verified search crawler fetched in this log. On one site a clean guide was
327
426
  // never fetched by Googlebot in 21 days and had 0 impressions; it was linked from too few pages.
328
427
  function sitemapCoverage(sitemap, crawlers, crawl, limit) {
329
428
  const fetched = new Set();
@@ -335,21 +434,25 @@ function sitemapCoverage(sitemap, crawlers, crawl, limit) {
335
434
  return { urls: sitemap.size, fetchedBySearch: sitemap.size - never.length, neverFetched: never.slice(0, limit), neverFetchedCount: never.length };
336
435
  }
337
436
 
338
- // Verified fetches per 7-day bucket ending on the last day: sitemap URLs only when a sitemap was given.
339
- function weekly(perDay, lastDay, firstDay) {
437
+ // Verified fetches per 7-day bucket ending on the last day: `fetches` counts sitemap URLs only when
438
+ // a sitemap was given, `pageFetches` every page. `share` sends the second, so a benchmark row
439
+ // means the same thing with or without a sitemap.
440
+ function weekly(perDay, lastDay, firstDay, perDayAll) {
340
441
  if (!lastDay) return [];
341
442
  const end = Date.parse(`${lastDay}T00:00:00Z`);
342
- const b = new Map();
343
- for (const [d, n] of perDay) {
344
- const w = Math.floor((end - Date.parse(`${d}T00:00:00Z`)) / (7 * 86_400_000));
345
- countBy(b, w, n);
346
- }
443
+ const bucket = (byDay) => {
444
+ const b = new Map();
445
+ for (const [d, n] of byDay) countBy(b, Math.floor((end - Date.parse(`${d}T00:00:00Z`)) / (7 * 86_400_000)), n);
446
+ return b;
447
+ };
448
+ const b = bucket(perDay);
449
+ const all = bucket(perDayAll);
347
450
  const out = [];
348
451
  const nWeeks = Math.floor((end - Date.parse(`${firstDay}T00:00:00Z`)) / (7 * 86_400_000));
349
452
  for (let w = nWeeks; w >= 0; w--) {
350
453
  const iso = ms => new Date(ms).toISOString().slice(0, 10);
351
454
  const lo = iso(end - (7 * w + 6) * 86_400_000);
352
- out.push({ from: lo < firstDay ? firstDay : lo, to: iso(end - 7 * w * 86_400_000), fetches: b.get(w) ?? 0 });
455
+ out.push({ from: lo < firstDay ? firstDay : lo, to: iso(end - 7 * w * 86_400_000), fetches: b.get(w) ?? 0, pageFetches: all.get(w) ?? 0 });
353
456
  }
354
457
  return out;
355
458
  }
@@ -1,7 +1,7 @@
1
1
  // Where a visit came from: the Referer of its first page view, or the landing URL's utm_source.
2
2
  //
3
3
  // utm_source wins because the AI assistants mostly send no Referer: ChatGPT tags its links
4
- // `?utm_source=chatgpt.com`, and on synacl.com that tag was the only trace of what became the
4
+ // `?utm_source=chatgpt.com`, and on one site that tag was the only trace of what became the
5
5
  // channel with the most tool use per visitor (2026-09). ChatGPT traffic never appears in GSC.
6
6
 
7
7
  // [source, kind, host test]. First match wins, so gemini.google.com sits before google.
package/src/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  export { AGENTS, CRAWLERS, detectAgent, detectCrawler, isSearchCrawler } from './agents.js';
5
5
  export { SCANNER_PATH_RE, isScannerPath } from './scanner.js';
6
6
  export {
7
- SOURCES, parseCidr, parseList, normIp,
7
+ SOURCES, FEED_URL, parseCidr, parseList, normIp,
8
8
  REFRESH_AFTER_MS, DROP_AFTER_MS, MIN_PREFIX, MAX_PREFIXES, MAX_BODY_CHARS,
9
9
  } from './ip-ranges.js';
10
10
  export { createVerifier, memoryStore } from './verifier.js';
@@ -14,3 +14,4 @@ export { digest, parseSitemap, parseGoal } from './digest/index.js';
14
14
  export { parseCsv } from './digest/csv.js';
15
15
  export { classifySource } from './digest/referrer.js';
16
16
  export { parseLine } from './digest/log.js';
17
+ export { sharePayload, validateShare, isoWeek, weekStart, addWeeks, SHARE_METRICS, SHARE_SCHEMA, SHARE_URL, SHARE_REFUSALS } from './share.js';
package/src/ip-ranges.js CHANGED
@@ -1,10 +1,7 @@
1
1
  // The vendors' published crawler IP lists: what tells a real ChatGPT-User from a scanner
2
- // wearing its User-Agent.
2
+ // wearing its User-Agent. The store is pluggable (verifier.js).
3
3
  //
4
- // Ported from the crawler-verification code Synacl Labs runs on synacl.com (2026-10). There it
5
- // lived behind a Redis key shared by a cluster; here the store is pluggable (verifier.js).
6
- //
7
- // WHY (measured on that site's nginx log, 2026-09-17 → 10-16). A scanner cycles AI user-agents:
4
+ // WHY (measured on one site's nginx log, 2026-09-17 → 10-16). A scanner cycles AI user-agents:
8
5
  // 217 of 221 ChatGPT-User requests from outside OpenAI's list came from IPs that also got a 444
9
6
  // (nginx's "close the connection, send nothing" answer to a scanner path), and DeepSeekBot /
10
7
  // CCBot / Grok / Perplexity-User / Claude-SearchBot were 100 % spoofed. Where a vendor publishes
@@ -46,6 +43,11 @@ const CLAUDE_BOTS = 'https://claude.com/crawling/bots.json';
46
43
 
47
44
  // Agent key (agents.js AGENTS / CRAWLERS) → its vendor's published list. Every URL answers
48
45
  // { creationTime, prefixes: [{ ipv4Prefix } | { ipv6Prefix }] } (all checked 2026-10-21).
46
+ // The hosted feed (cloud/): every list above in one document, refetched from the vendors every
47
+ // 6 h. The CLI reads it by default (the charter: the feed is opt-out); the library does not, and
48
+ // cloud's own feed job must not, since it is what builds the feed.
49
+ export const FEED_URL = 'https://api.siteread.dev/v1/lists.json';
50
+
49
51
  export const SOURCES = Object.freeze([
50
52
  { agent: 'chatgpt-user', url: 'https://openai.com/chatgpt-user.json' },
51
53
  { agent: 'oai-searchbot', url: 'https://openai.com/searchbot.json' },
package/src/scanner.js CHANGED
@@ -3,8 +3,7 @@
3
3
  // "444 rule" (nginx answers these with 444, "close, send nothing") the verifier falls back on
4
4
  // when a vendor publishes no IP list (createVerifier's `isKnownScannerIp`).
5
5
  //
6
- // Ported from the edge classifier Synacl Labs runs on synacl.com (2026-10), keeping only the
7
- // host-blind, product-agnostic rules; that site's own carve-outs are gone. A site with a real
6
+ // Only host-blind, product-agnostic rules live here, no one site's carve-outs. A site with a real
8
7
  // path that matches (a /install.sh, a Decap CMS /admin/config.yml) passes `allow`.
9
8
  //
10
9
  // Classify on the REQUEST PATH ONLY, never the User-Agent: a scanner whose UA was literally
package/src/share.js ADDED
@@ -0,0 +1,221 @@
1
+ // `siteread share`: one week of a digest → the numbers a site can choose to send to the public
2
+ // benchmark, and the validator the endpoint runs on whatever arrives. Opt-in: the CLI prints the
3
+ // exact bytes it will send and sends nothing without a yes.
4
+ //
5
+ // WHAT NEVER LEAVES: hostnames, URLs or paths, visitor IPs, user-agents, query text, goal labels.
6
+ // The payload is built from an allowlist of numeric metrics, so a field added to the digest later
7
+ // cannot leak by accident, and the endpoint rejects any key or string it does not know for the
8
+ // same reason. The only strings are the schema's own: a random id, an ISO week and the version.
9
+ //
10
+ // The id is 128 random bits the CLI keeps in .siteread/share-id. It is what makes the benchmark's
11
+ // `n` count sites rather than shares, and lets a site that shares the same week twice replace its
12
+ // row instead of counting twice. Delete the file to start over as a new site.
13
+
14
+ import { VERSION } from './version.js';
15
+
16
+ export const SHARE_SCHEMA = 1;
17
+ // Where `share` sends by default: the service's address is written here and nowhere else.
18
+ export const SHARE_URL = 'https://api.siteread.dev/v1/share';
19
+
20
+ const DAY = 86_400_000;
21
+ const BIG = 1e9;
22
+
23
+ // Every metric describes the last 7 days the digest covers (the digest's newest 7-day bucket),
24
+ // except aiSpoofedPct, a ratio over the whole log. Bounds reject garbage, not big sites.
25
+ export const SHARE_METRICS = Object.freeze({
26
+ people: { int: true, min: 0, max: BIG, about: 'people who read a page (an IP with a browser UA that loaded an asset and never probed a scanner path)' },
27
+ peopleFromSearch: { int: true, min: 0, max: BIG, about: 'people whose first visit in the week came from a search engine' },
28
+ peopleFromAi: { int: true, min: 0, max: BIG, about: 'people whose first visit in the week came from an AI assistant (Referer or utm_source)' },
29
+ peopleFromSocial: { int: true, min: 0, max: BIG, about: 'people whose first visit in the week came from a social site or forum' },
30
+ aiAnswerFetches: { int: true, min: 0, max: BIG, about: 'page fetches by AI agents reading for a person right now (ChatGPT-User, Perplexity-User, Claude-User), verified against vendor lists' },
31
+ aiSearchFetches: { int: true, min: 0, max: BIG, about: 'page fetches by AI search indexers (OAI-SearchBot, PerplexityBot, Claude-SearchBot, Applebot), verified' },
32
+ aiTrainingFetches: { int: true, min: 0, max: BIG, about: 'page fetches by AI training crawlers (GPTBot, ClaudeBot), verified' },
33
+ searchEngineFetches: { int: true, min: 0, max: BIG, about: 'page fetches by Googlebot and bingbot, verified' },
34
+ aiSpoofedPct: { int: false, min: 0, max: 100, about: 'share of requests under an AI user-agent that were spoofed, over the whole log (%)' },
35
+ gscImpressions: { int: true, min: 0, max: BIG, about: 'Search Console impressions' },
36
+ gscClicks: { int: true, min: 0, max: BIG, about: 'Search Console clicks' },
37
+ gscCtrPct: { int: false, min: 0, max: 100, about: 'Search Console click-through rate (%)' },
38
+ gscPosition: { int: false, min: 1, max: 1000, lowerIsBetter: true, about: 'Search Console position, impression-weighted (lower is better)' },
39
+ });
40
+
41
+ // The `code` a refused share carries (validateShare's own, then the endpoint's).
42
+ export const SHARE_REFUSALS = Object.freeze([
43
+ 'body', 'unknown-field', 'schema', 'id', 'week', 'version', 'unknown-metric', 'metric-value',
44
+ 'content-type', 'too-large', 'not-json', 'future-week', 'old-week', 'proof',
45
+ ]);
46
+
47
+ export const ID_RE = /^[0-9a-f]{32}$/;
48
+ const WEEK_RE = /^(\d{4})-W(\d{2})$/;
49
+ const VERSION_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}(-[0-9a-z.]{1,20})?$/;
50
+ const KEYS = new Set(['schema', 'id', 'week', 'siteread', 'metrics', 'proof']);
51
+ const PROOF_RE = /^\d{1,20}$/;
52
+
53
+ // PROOF OF WORK. A share id is free to make, so a fixed cap on new sites a day was one sender's
54
+ // to use up: a hundred made-up ids in a second, and every real new site refused until midnight.
55
+ // The service never reads an address, so it cannot tell one sender from many; what it can do is
56
+ // make each NEW id cost work. A site's first share carries `proof`, a nonce such that
57
+ // SHA-256(PROOF_PREFIX + id + ":" + nonce) starts with enough zero bits, made once and kept next
58
+ // to the id. The bits a new id needs rise by one for every PROOF_STEP new sites that day (UTC),
59
+ // so on a quiet day it is ~2 s on a laptop, and a flood doubles its own price every 50 ids: 500
60
+ // fakes in a day is ~30 CPU-hours, and a real site still gets in by paying the day's price. Known
61
+ // ids never pay again. Checking is one hash.
62
+ export const PROOF_PREFIX = 'siteread-site-v1:';
63
+ export const PROOF_BASE_BITS = 22;
64
+ export const PROOF_STEP = 50;
65
+
66
+ /** The zero bits a new site's proof needs when `newToday` new sites came in today. */
67
+ export const proofBitsFor = (newToday, base = PROOF_BASE_BITS) => base + Math.floor(newToday / PROOF_STEP);
68
+
69
+ /** Leading zero bits of a digest. */
70
+ export function leadingZeroBits(bytes) {
71
+ let n = 0;
72
+ for (const b of bytes) {
73
+ if (b === 0) { n += 8; continue; }
74
+ return n + Math.clz32(b) - 24;
75
+ }
76
+ return n;
77
+ }
78
+
79
+ const dayMs = d => Date.parse(`${d}T00:00:00Z`);
80
+ const isoDay = ms => new Date(ms).toISOString().slice(0, 10);
81
+
82
+ /** The ISO 8601 week ('2026-W42') a day (YYYY-MM-DD or ms) falls in. */
83
+ export function isoWeek(day) {
84
+ const t = typeof day === 'number' ? Math.floor(day / DAY) * DAY : dayMs(day);
85
+ const thursday = t + (3 - ((new Date(t).getUTCDay() + 6) % 7)) * DAY;
86
+ const year = new Date(thursday).getUTCFullYear();
87
+ const week = 1 + Math.floor((thursday - Date.UTC(year, 0, 1)) / (7 * DAY));
88
+ return `${year}-W${String(week).padStart(2, '0')}`;
89
+ }
90
+
91
+ /** ms of the Monday that starts an ISO week, or NaN when it is not a real week (2026-W54). */
92
+ export function weekStart(week) {
93
+ const m = WEEK_RE.exec(String(week));
94
+ if (!m) return NaN;
95
+ const jan4 = Date.UTC(Number(m[1]), 0, 4);
96
+ const t = jan4 - ((new Date(jan4).getUTCDay() + 6) % 7) * DAY + (Number(m[2]) - 1) * 7 * DAY;
97
+ return isoWeek(t) === week ? t : NaN;
98
+ }
99
+
100
+ /** An ISO week moved by n weeks. */
101
+ export function addWeeks(week, n) {
102
+ return isoWeek(weekStart(week) + n * 7 * DAY);
103
+ }
104
+
105
+ // The digest's newest 7-day bucket is labelled with the ISO week holding most of its days.
106
+ const weekOfWindow = to => isoWeek(dayMs(to) - 3 * DAY);
107
+
108
+ const pct1 = n => Math.round(n * 10) / 10;
109
+
110
+ /**
111
+ * A digest (its JSON) → { payload, notes }. `notes` says, in plain words, each metric left out
112
+ * and why. Throws when there is no full week to share.
113
+ */
114
+ export function sharePayload(digest, { id, proof = null, version = VERSION } = {}) {
115
+ if (!ID_RE.test(String(id))) throw new Error('share needs a 32-hex-digit id');
116
+ const metrics = {};
117
+ const notes = [];
118
+ let week = null;
119
+
120
+ const log = digest?.log;
121
+ if (log?.range?.to) {
122
+ const to = log.range.to;
123
+ const from = isoDay(dayMs(to) - 6 * DAY);
124
+ if (log.range.from > from) {
125
+ notes.push(`log: the log covers ${log.range.from} → ${to}, less than a full week; log numbers left out`);
126
+ } else {
127
+ week = weekOfWindow(to);
128
+ // people.weeks lists only weeks someone came in: none ending on the last day is a 0.
129
+ metrics.people = log.people?.weeks?.find(w => w.to === to)?.people ?? 0;
130
+ const arrived = { search: 0, ai: 0, social: 0 };
131
+ for (const s of log.sources ?? []) {
132
+ if (!(s.kind in arrived)) continue;
133
+ for (const [d, n] of Object.entries(s.landingDays ?? {})) if (d >= from && d <= to) arrived[s.kind] += n;
134
+ }
135
+ metrics.peopleFromSearch = arrived.search;
136
+ metrics.peopleFromAi = arrived.ai;
137
+ metrics.peopleFromSocial = arrived.social;
138
+
139
+ const fetches = { aiAnswerFetches: 0, aiSearchFetches: 0, aiTrainingFetches: 0, searchEngineFetches: 0 };
140
+ let complete = true;
141
+ let aiRequests = 0, aiSpoofed = 0;
142
+ for (const c of log.crawlers ?? []) {
143
+ if (c.kind === 'ai') { aiRequests += c.requests ?? 0; aiSpoofed += c.spoofed?.requests ?? 0; }
144
+ const w = c.verified?.perWeek?.at(-1);
145
+ if (!w || w.to !== to) continue;
146
+ // A digest before 0.2.1 has only `fetches`, which counts sitemap URLs alone when it
147
+ // had a sitemap: that would not mean the same as everyone else's row.
148
+ const n = w.pageFetches ?? (c.verified.sitemapFetches == null ? w.fetches : null);
149
+ if (n == null) { complete = false; continue; }
150
+ const key = c.kind === 'crawler'
151
+ ? (c.purpose === 'search' ? 'searchEngineFetches' : null)
152
+ : { answer: 'aiAnswerFetches', search: 'aiSearchFetches', training: 'aiTrainingFetches' }[c.purpose];
153
+ if (key) fetches[key] += n;
154
+ }
155
+ if (complete) Object.assign(metrics, fetches);
156
+ else notes.push('log: this digest predates per-week page fetches (siteread < 0.2.1 with a sitemap); crawler numbers left out, run the digest again');
157
+ if (aiRequests) metrics.aiSpoofedPct = pct1((100 * aiSpoofed) / aiRequests);
158
+ else notes.push('log: no requests under AI user-agents; aiSpoofedPct left out');
159
+ }
160
+ } else {
161
+ notes.push(log ? 'log: no parseable lines in the log; log numbers left out' : 'log: no access log in this digest; log numbers left out');
162
+ }
163
+
164
+ const gscWeek = digest?.gsc?.weeks?.at(-1);
165
+ if (gscWeek) {
166
+ const label = weekOfWindow(gscWeek.to);
167
+ if (gscWeek.days !== 7) {
168
+ notes.push(`gsc: the export's last week ${gscWeek.from} → ${gscWeek.to} has ${gscWeek.days} days; Search Console numbers left out`);
169
+ } else if (week && label !== week) {
170
+ notes.push(`gsc: the export's last full week is ${label}, the log's is ${week}; Search Console numbers left out (export again, or pass --from/--to to the digest)`);
171
+ } else {
172
+ week ??= label;
173
+ metrics.gscImpressions = gscWeek.impressions;
174
+ metrics.gscClicks = gscWeek.clicks;
175
+ if (gscWeek.ctr != null) metrics.gscCtrPct = gscWeek.ctr;
176
+ if (gscWeek.position != null) metrics.gscPosition = gscWeek.position;
177
+ }
178
+ } else if (!digest?.gsc) {
179
+ notes.push('gsc: no Search Console export in this digest; Search Console numbers left out');
180
+ }
181
+
182
+ if (!week || !Object.keys(metrics).length) throw new Error(`nothing to share: the digest has no full week of numbers${notes.length ? ` (${notes.join('; ')})` : ''}`);
183
+ const payload = { schema: SHARE_SCHEMA, id, week, siteread: version, metrics };
184
+ if (proof != null) payload.proof = String(proof);
185
+ const check = validateShare(payload);
186
+ if (!check.ok) throw new Error(`the digest gave a number the endpoint would refuse: ${check.error}`);
187
+ return { payload: check.share, notes };
188
+ }
189
+
190
+ /**
191
+ * What the endpoint accepts, nothing more: { ok: true, share } with the metrics in schema order,
192
+ * or { ok: false, error, code }. `code` is one of SHARE_REFUSALS, for counting refusals without
193
+ * keeping the message (which can echo a field name the sender chose). It does not know the
194
+ * date: the endpoint checks the week's age itself.
195
+ */
196
+ export function validateShare(body) {
197
+ const bad = (code, error) => ({ ok: false, error, code });
198
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return bad('body', 'the body must be a JSON object');
199
+ for (const k of Object.keys(body)) if (!KEYS.has(k)) return bad('unknown-field', `unknown field "${k}"`);
200
+ if (body.schema !== SHARE_SCHEMA) return bad('schema', `schema must be ${SHARE_SCHEMA}`);
201
+ if (typeof body.id !== 'string' || !ID_RE.test(body.id)) return bad('id', 'id must be 32 lowercase hex digits');
202
+ if (typeof body.week !== 'string' || !Number.isFinite(weekStart(body.week))) return bad('week', 'week must be an ISO week like 2026-W42');
203
+ if (typeof body.siteread !== 'string' || !VERSION_RE.test(body.siteread)) return bad('version', 'siteread must be a version like 0.3.0');
204
+ if (body.proof !== undefined && (typeof body.proof !== 'string' || !PROOF_RE.test(body.proof))) return bad('proof', 'proof must be a string of digits');
205
+ const m = body.metrics;
206
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return bad('metric-value', 'metrics must be an object');
207
+ const metrics = {};
208
+ for (const k of Object.keys(m)) if (!Object.hasOwn(SHARE_METRICS, k)) return bad('unknown-metric', `unknown metric "${k}"`);
209
+ for (const [k, spec] of Object.entries(SHARE_METRICS)) {
210
+ if (!Object.hasOwn(m, k)) continue;
211
+ const v = m[k];
212
+ if (typeof v !== 'number' || !Number.isFinite(v)) return bad('metric-value', `${k} must be a number`);
213
+ if (spec.int && !Number.isInteger(v)) return bad('metric-value', `${k} must be a whole number`);
214
+ if (v < spec.min || v > spec.max) return bad('metric-value', `${k} must be between ${spec.min} and ${spec.max}`);
215
+ metrics[k] = v;
216
+ }
217
+ if (!Object.keys(metrics).length) return bad('metric-value', 'metrics is empty');
218
+ const share = { schema: body.schema, id: body.id, week: body.week, siteread: body.siteread, metrics };
219
+ if (body.proof !== undefined) share.proof = body.proof;
220
+ return { ok: true, share };
221
+ }
package/src/verifier.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // A vendor list always wins over the 444 rule: ClaudeBot once followed a broken README link
10
10
  // into a scanner path, and the 444 rule called a real crawler spoofed (ip-ranges.js).
11
11
  //
12
- // Refresh rules are the ported ones (ip-ranges.js): refetch a list when the stored copy is
12
+ // Refresh rules (ip-ranges.js): refetch a list when the stored copy is
13
13
  // > 24 h old, keep the old copy when a fetch fails, drop a copy nobody could refresh for 8 days.
14
14
  // Nothing here throws.
15
15
 
@@ -38,7 +38,8 @@ export function memoryStore(initial = null) {
38
38
  * @param {Array<{agent: string, url: string}>} [opts.sources]
39
39
  * @param {() => number} [opts.now]
40
40
  * @param {{warn?: Function, error?: Function}} [opts.log]
41
- * @param {string|null} [opts.feedUrl] one merged document instead of the vendor URLs (opt-in)
41
+ * @param {string|null} [opts.feedUrl] one merged document instead of the vendor URLs
42
+ * @param {boolean} [opts.fallback] with feedUrl: fetch from the vendors what the feed could not give
42
43
  * @param {(ip: string) => boolean} [opts.isKnownScannerIp] the 444 rule, for agents with no list
43
44
  */
44
45
  export function createVerifier({
@@ -48,6 +49,7 @@ export function createVerifier({
48
49
  now = Date.now,
49
50
  log = console,
50
51
  feedUrl = null,
52
+ fallback = false,
51
53
  isKnownScannerIp = null,
52
54
  } = {}) {
53
55
  // One entry per agent (the first wins), in the order given.
@@ -140,6 +142,14 @@ export function createVerifier({
140
142
  for (const { agent } of due) fail(out, agent, new Error('no fetch available'), agent);
141
143
  } else if (feedUrl) {
142
144
  await fetchFromFeed(due, t, doc, out);
145
+ // The feed is a shortcut to the vendors, never the only way to them: what it
146
+ // could not give (down, stale, a list missing) comes from the vendor itself.
147
+ const missed = fallback ? due.filter(s => out.failed.includes(s.agent)) : [];
148
+ if (missed.length) {
149
+ for (const { agent } of missed) delete out.errors[agent];
150
+ out.failed = out.failed.filter(a => !missed.some(s => s.agent === a));
151
+ await fetchFromVendors(missed, t, doc, out);
152
+ }
143
153
  } else {
144
154
  await fetchFromVendors(due, t, doc, out);
145
155
  }
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Kept in step with package.json by test/package.test.js (no JSON import: Node 20 needs no flag for this).
2
- export const VERSION = '0.2.0';
2
+ export const VERSION = '0.2.1';