siteread 0.2.0 → 0.2.2
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 +110 -17
- package/bin/siteread.js +193 -10
- package/package.json +11 -2
- package/src/agents.js +2 -5
- package/src/digest/gsc.js +1 -1
- package/src/digest/index.js +7 -2
- package/src/digest/log.js +129 -26
- package/src/digest/referrer.js +1 -1
- package/src/index.js +2 -1
- package/src/ip-ranges.js +37 -18
- package/src/scanner.js +1 -2
- package/src/share.js +221 -0
- package/src/verifier.js +14 -3
- package/src/version.js +1 -1
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
|
|
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,87 @@ $ 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
|
-
|
|
75
|
-
|
|
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, in siteread's Claude Code plugin
|
|
85
|
+
(`/plugin install siteread@siteread` after `/plugin marketplace add synacl-iot/siteread`), turns the
|
|
86
|
+
JSON into a weekly memo.
|
|
87
|
+
|
|
88
|
+
## Share (opt-in)
|
|
89
|
+
|
|
90
|
+
`share` sends one week of a digest's aggregate numbers to the public benchmark and prints where each
|
|
91
|
+
number stands among the quartiles the benchmark publishes. It prints the exact bytes first and sends nothing without a yes (a
|
|
92
|
+
prompt, or `--yes` in a script). `--dry-run` prints and stops.
|
|
93
|
+
|
|
94
|
+
```console
|
|
95
|
+
$ npx siteread share --dry-run
|
|
96
|
+
siteread: the whole payload from .siteread/digests/2026-10-18.json, byte for byte, for https://api.siteread.dev/v1/share:
|
|
97
|
+
{
|
|
98
|
+
"schema": 1,
|
|
99
|
+
"id": "3803e566762f2e56ba381b993ddf466e",
|
|
100
|
+
"week": "2026-W42",
|
|
101
|
+
"siteread": "0.2.1",
|
|
102
|
+
"metrics": {
|
|
103
|
+
"people": 4,
|
|
104
|
+
"peopleFromSearch": 1,
|
|
105
|
+
"peopleFromAi": 0,
|
|
106
|
+
"peopleFromSocial": 2,
|
|
107
|
+
"aiAnswerFetches": 0,
|
|
108
|
+
"aiSearchFetches": 0,
|
|
109
|
+
"aiTrainingFetches": 2,
|
|
110
|
+
"searchEngineFetches": 8,
|
|
111
|
+
"aiSpoofedPct": 25,
|
|
112
|
+
"gscImpressions": 238,
|
|
113
|
+
"gscClicks": 3,
|
|
114
|
+
"gscCtrPct": 1.3,
|
|
115
|
+
"gscPosition": 17.9
|
|
116
|
+
},
|
|
117
|
+
"proof": "2746561"
|
|
118
|
+
}
|
|
119
|
+
siteread: not sent (--dry-run)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The answer places each number in a band among the published 25th, 50th and 75th percentiles, and
|
|
123
|
+
only for numbers the benchmark page already shows a spread for; for the rest it gives the count of
|
|
124
|
+
sites so far. It is read from the published benchmark (rebuilt hourly), not from other sites' rows,
|
|
125
|
+
so it never tells you more than the page does. With 14 anonymous sites, 9 of them with Search
|
|
126
|
+
Console numbers (made up for this example):
|
|
127
|
+
|
|
128
|
+
```console
|
|
129
|
+
sent 2026-W42. Against the benchmark built 2026-10-21 09:00 UTC: 14 anonymous sites, 2026-W39 to 2026-W42
|
|
130
|
+
|
|
131
|
+
metric yours where
|
|
132
|
+
people 4 below the 25th percentile
|
|
133
|
+
peopleFromAi 0 25th to 50th percentile
|
|
134
|
+
gscImpressions 238 9 sites; shown from 10
|
|
135
|
+
gscPosition 17.9 9 sites; shown from 10
|
|
136
|
+
…
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- **What is sent:** the numbers above for the digest's last 7 days (`aiSpoofedPct` is over the whole
|
|
140
|
+
log), a week label, the version, and a random id. `SHARE_METRICS` defines each one.
|
|
141
|
+
- **What is never sent:** hostnames, URLs or paths, visitor IPs, user-agents, query text, goal
|
|
142
|
+
labels. The payload is built from an allowlist of numbers, and the endpoint refuses anything else.
|
|
143
|
+
- **The id** is 128 random bits in `.siteread/share-id`, made on first use. It makes the benchmark
|
|
144
|
+
count sites rather than shares, and a second share of the same week replaces the first. Delete the
|
|
145
|
+
file to start over as a new site.
|
|
146
|
+
- **The proof** is a proof of work tied to the id: a number that makes SHA-256 of the id start with
|
|
147
|
+
22 zero bits, a few seconds of CPU once, kept in `.siteread/share-proof`. Only a site's first
|
|
148
|
+
share needs it. Ids are free to make, so without it one sender could invent a flood of sites; the
|
|
149
|
+
service never reads an address, so it makes each new site cost time instead, and the price rises
|
|
150
|
+
by one bit for every 50 new sites in a day. On a busy day the service asks for more; the CLI makes
|
|
151
|
+
it and tells you to run `share` again.
|
|
152
|
+
- A part with less than a full week (a 4-day log, an export whose last week is cut short) is left
|
|
153
|
+
out and the CLI says so. `--endpoint` (or `SITEREAD_SHARE_URL`) sends elsewhere: `https://` only,
|
|
154
|
+
plain `http://` only to localhost.
|
|
155
|
+
|
|
156
|
+
In the library, `sharePayload(digest, { id })` returns `{ payload, notes }`, and `validateShare(body)`
|
|
157
|
+
is the endpoint's check.
|
|
76
158
|
|
|
77
159
|
## Library
|
|
78
160
|
|
|
@@ -98,7 +180,8 @@ verifier.check('216.73.216.12', 'Mozilla/5.0 … ClaudeBot/1.0; +claudebot@anthr
|
|
|
98
180
|
| `now` | `Date.now` | a clock, for tests |
|
|
99
181
|
| `log` | `console` | `{ warn, error }`; pass `{}` for silence |
|
|
100
182
|
| `isKnownScannerIp` | none | `(ip) => boolean`, the 444 rule (below) |
|
|
101
|
-
| `feedUrl` | none | one merged document instead of the vendor URLs (below) |
|
|
183
|
+
| `feedUrl` | none | one merged document instead of the vendor URLs (below); `FEED_URL` is the hosted one |
|
|
184
|
+
| `fallback` | `false` | with `feedUrl`: fetch from the vendors what the feed could not give |
|
|
102
185
|
|
|
103
186
|
It returns:
|
|
104
187
|
|
|
@@ -213,21 +296,31 @@ All of them answer `{ "creationTime": …, "prefixes": [{ "ipv4Prefix": … } |
|
|
|
213
296
|
failure, not as an empty list.
|
|
214
297
|
- **Drop:** a copy nobody could refresh for 8 days is dropped, so a vendor's moved ranges cannot mark
|
|
215
298
|
its new IPs as spoofed forever. With no copy, the verdict is `unknown`, never `spoofed`.
|
|
216
|
-
- **Bounds:** a body over 2,000,000 characters
|
|
217
|
-
/
|
|
299
|
+
- **Bounds:** a body over 2,000,000 characters or more than 20,000 prefixes is rejected, and a list
|
|
300
|
+
holding a prefix broader than /16 (IPv4) or /32 (IPv6) is refused whole, keeping the previous
|
|
301
|
+
copy. The broadest real prefix in any list is a /16 (IPv4) and a /64 (IPv6); a `/0` would verify
|
|
302
|
+
every address, and since the CLI reads the hosted feed, the limit is what stops a tampered feed
|
|
303
|
+
from vouching for a whole provider's range. From the feed, a refused list is fetched from its
|
|
304
|
+
vendor instead.
|
|
218
305
|
|
|
219
306
|
## Privacy
|
|
220
307
|
|
|
221
|
-
Local use sends nothing anywhere except plain GETs of
|
|
222
|
-
`siteread/<version
|
|
223
|
-
|
|
308
|
+
Local use sends nothing anywhere except plain GETs of public crawler lists, with the User-Agent
|
|
309
|
+
`siteread/<version>`: the siteread feed by default, or the vendors' URLs above with `--no-feed` (or
|
|
310
|
+
`SITEREAD_FEED_URL=off`), and those vendors whenever the feed fails. The feed's service keeps no
|
|
311
|
+
address; it counts fetches per day by siteread version. No hostnames, URLs or visitor IPs leave
|
|
312
|
+
your machine. There is no reverse-DNS
|
|
313
|
+
check for the same reason: a PTR lookup sends the visitor's IP to your resolver. `share` is the one
|
|
314
|
+
exception: opt-in, its exact payload printed first, aggregate numbers only.
|
|
224
315
|
|
|
225
316
|
`feedUrl` points the verifier at one merged document,
|
|
226
|
-
`{ "lists": { "<agent>": { "prefixes": [...], "fetchedAt": "..." } } }`, instead of the vendors
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
317
|
+
`{ "lists": { "<agent>": { "prefixes": [...], "fetchedAt": "..." } } }`, instead of the vendors: the
|
|
318
|
+
hosted feed is `https://api.siteread.dev/v1/lists.json` (`FEED_URL`, this repo's `cloud/`, rebuilt
|
|
319
|
+
from the vendors every 6 h). The CLI uses it by default with `fallback: true`, which fetches from the
|
|
320
|
+
vendors whatever the feed could not give; the library leaves both off unless you pass them. Every
|
|
321
|
+
prefix from a feed is re-validated like a vendor's, and a `fetchedAt` in the future is clamped to
|
|
322
|
+
now.
|
|
230
323
|
|
|
231
324
|
## License
|
|
232
325
|
|
|
233
|
-
MIT.
|
|
326
|
+
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
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "siteread",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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
|
+
"homepage": "https://siteread.dev",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/synacl-iot/siteread.git",
|
|
9
|
+
"directory": "packages/verify"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/synacl-iot/siteread/issues"
|
|
13
|
+
},
|
|
5
14
|
"type": "module",
|
|
6
15
|
"engines": {
|
|
7
16
|
"node": ">=20"
|
|
@@ -19,7 +28,7 @@
|
|
|
19
28
|
"README.md"
|
|
20
29
|
],
|
|
21
30
|
"scripts": {
|
|
22
|
-
"test": "node --test"
|
|
31
|
+
"test": "node --test test/*.test.js"
|
|
23
32
|
},
|
|
24
33
|
"keywords": [
|
|
25
34
|
"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
|
|
3
|
-
//
|
|
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
|
|
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
|
package/src/digest/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
48
|
-
if (!
|
|
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
|
|
154
|
-
const
|
|
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: !
|
|
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
|
|
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
|
|
339
|
-
|
|
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
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
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
|
}
|
package/src/digest/referrer.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
@@ -35,9 +32,15 @@ export const FETCH_TIMEOUT_MS = 8_000;
|
|
|
35
32
|
// A list is a few hundred prefixes; these bound what a broken (or hostile) response can cost.
|
|
36
33
|
export const MAX_BODY_CHARS = 2_000_000;
|
|
37
34
|
export const MAX_PREFIXES = 20_000;
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
35
|
+
// The broadest prefix a list may hold. A /0 would verify EVERY address, and since the CLI reads
|
|
36
|
+
// the hosted feed by default, the feed's server vouches for every client's "verified": if it were
|
|
37
|
+
// ever taken over, it could add a broad range to any agent and every spoofer in it would pass.
|
|
38
|
+
// Measured 2026-10-22, the broadest real prefix in any list is OpenAI's /16 (chatgpt-user) for
|
|
39
|
+
// IPv4 and Google's /64 for IPv6, so these limits cost nothing real and cut the worst case from
|
|
40
|
+
// a /8 to a /16. A list holding a broader prefix is refused whole (its previous copy kept, the
|
|
41
|
+
// error reported), never trimmed: trimming would silently turn a vendor's real new range into
|
|
42
|
+
// "spoofed", and a refusal says out loud that a limit or a list needs a look.
|
|
43
|
+
export const MIN_PREFIX = Object.freeze({ ipv4: 16, ipv6: 32 });
|
|
41
44
|
// Two processes sharing a store can disagree on the time by a little. A fetchedAt further in
|
|
42
45
|
// the future than this is not skew: it would keep a list "fresh" forever and defeat the drop.
|
|
43
46
|
const CLOCK_SKEW_MS = 5 * 60_000;
|
|
@@ -46,6 +49,11 @@ const CLAUDE_BOTS = 'https://claude.com/crawling/bots.json';
|
|
|
46
49
|
|
|
47
50
|
// Agent key (agents.js AGENTS / CRAWLERS) → its vendor's published list. Every URL answers
|
|
48
51
|
// { creationTime, prefixes: [{ ipv4Prefix } | { ipv6Prefix }] } (all checked 2026-10-21).
|
|
52
|
+
// The hosted feed (cloud/): every list above in one document, refetched from the vendors every
|
|
53
|
+
// 6 h. The CLI reads it by default (the charter: the feed is opt-out); the library does not, and
|
|
54
|
+
// cloud's own feed job must not, since it is what builds the feed.
|
|
55
|
+
export const FEED_URL = 'https://api.siteread.dev/v1/lists.json';
|
|
56
|
+
|
|
49
57
|
export const SOURCES = Object.freeze([
|
|
50
58
|
{ agent: 'chatgpt-user', url: 'https://openai.com/chatgpt-user.json' },
|
|
51
59
|
{ agent: 'oai-searchbot', url: 'https://openai.com/searchbot.json' },
|
|
@@ -62,8 +70,14 @@ export const SOURCES = Object.freeze([
|
|
|
62
70
|
{ agent: 'bingbot', url: 'https://www.bing.com/toolbox/bingbot.json' },
|
|
63
71
|
].map(Object.freeze));
|
|
64
72
|
|
|
65
|
-
/** 'a.b.c.d/n' or 'x::/n' → { type, net, prefix, cidr } (normalised), or null. */
|
|
73
|
+
/** 'a.b.c.d/n' or 'x::/n' → { type, net, prefix, cidr } (normalised), or null; MIN_PREFIX applies. */
|
|
66
74
|
export function parseCidr(raw) {
|
|
75
|
+
const p = cidrOf(raw);
|
|
76
|
+
return p && p.prefix >= MIN_PREFIX[p.type] ? p : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** parseCidr without the breadth limit, so a list parser can tell "too broad" from "not a prefix". */
|
|
80
|
+
function cidrOf(raw) {
|
|
67
81
|
if (typeof raw !== 'string') return null;
|
|
68
82
|
const s = raw.trim();
|
|
69
83
|
const slash = s.indexOf('/');
|
|
@@ -74,30 +88,35 @@ export function parseCidr(raw) {
|
|
|
74
88
|
const prefix = Number(bits);
|
|
75
89
|
const type = net.isIPv4(addr) ? 'ipv4' : net.isIPv6(addr) ? 'ipv6' : null;
|
|
76
90
|
if (!type) return null;
|
|
77
|
-
if (prefix
|
|
91
|
+
if (prefix > (type === 'ipv4' ? 32 : 128)) return null;
|
|
78
92
|
return { type, net: addr.toLowerCase(), prefix, cidr: `${addr.toLowerCase()}/${prefix}` };
|
|
79
93
|
}
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
// One prefix of a list: skipped when it is not a prefix at all, the whole list refused when it
|
|
96
|
+
// is one broader than MIN_PREFIX (see there).
|
|
97
|
+
function addPrefix(out, raw) {
|
|
98
|
+
const p = cidrOf(raw);
|
|
99
|
+
if (!p) return;
|
|
100
|
+
if (p.prefix < MIN_PREFIX[p.type]) throw new Error(`${p.cidr} is broader than /${MIN_PREFIX[p.type]}; list refused`);
|
|
101
|
+
out.add(p.cidr);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The vendors' shared format: { prefixes: [{ ipv4Prefix } | { ipv6Prefix }] } → [cidr]. Throws on a too-broad prefix. */
|
|
82
105
|
export function parseList(body) {
|
|
83
106
|
const items = Array.isArray(body?.prefixes) ? body.prefixes : [];
|
|
84
107
|
const out = new Set();
|
|
85
108
|
for (const it of items) {
|
|
86
109
|
if (out.size >= MAX_PREFIXES) break;
|
|
87
|
-
|
|
88
|
-
if (p) out.add(p.cidr);
|
|
110
|
+
addPrefix(out, it?.ipv4Prefix ?? it?.ipv6Prefix);
|
|
89
111
|
}
|
|
90
112
|
return [...out];
|
|
91
113
|
}
|
|
92
114
|
|
|
93
|
-
/** A stored copy's prefixes ([cidr]) → [cidr], re-validated and bounded. */
|
|
115
|
+
/** A feed's or a stored copy's prefixes ([cidr]) → [cidr], re-validated and bounded. Throws on a too-broad prefix. */
|
|
94
116
|
export function parsePrefixes(list) {
|
|
95
117
|
if (!Array.isArray(list)) return [];
|
|
96
118
|
const out = new Set();
|
|
97
|
-
for (const c of list.slice(0, MAX_PREFIXES))
|
|
98
|
-
const p = parseCidr(c);
|
|
99
|
-
if (p) out.add(p.cidr);
|
|
100
|
-
}
|
|
119
|
+
for (const c of list.slice(0, MAX_PREFIXES)) addPrefix(out, c);
|
|
101
120
|
return [...out];
|
|
102
121
|
}
|
|
103
122
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
@@ -93,7 +95,8 @@ export function createVerifier({
|
|
|
93
95
|
const lists = body && typeof body.lists === 'object' && body.lists ? body.lists : {};
|
|
94
96
|
for (const { agent } of due) {
|
|
95
97
|
const l = Object.hasOwn(lists, agent) ? lists[agent] : null;
|
|
96
|
-
|
|
98
|
+
let prefixes;
|
|
99
|
+
try { prefixes = parsePrefixes(l?.prefixes); } catch (err) { fail(out, agent, err, 'feed'); continue; }
|
|
97
100
|
// A future fetchedAt is clamped to now: it would otherwise keep the copy fresh forever.
|
|
98
101
|
const at = Math.min(fetchedAtMs(l), t);
|
|
99
102
|
const stored = fetchedAtMs(doc.lists[agent]);
|
|
@@ -140,6 +143,14 @@ export function createVerifier({
|
|
|
140
143
|
for (const { agent } of due) fail(out, agent, new Error('no fetch available'), agent);
|
|
141
144
|
} else if (feedUrl) {
|
|
142
145
|
await fetchFromFeed(due, t, doc, out);
|
|
146
|
+
// The feed is a shortcut to the vendors, never the only way to them: what it
|
|
147
|
+
// could not give (down, stale, a list missing) comes from the vendor itself.
|
|
148
|
+
const missed = fallback ? due.filter(s => out.failed.includes(s.agent)) : [];
|
|
149
|
+
if (missed.length) {
|
|
150
|
+
for (const { agent } of missed) delete out.errors[agent];
|
|
151
|
+
out.failed = out.failed.filter(a => !missed.some(s => s.agent === a));
|
|
152
|
+
await fetchFromVendors(missed, t, doc, out);
|
|
153
|
+
}
|
|
143
154
|
} else {
|
|
144
155
|
await fetchFromVendors(due, t, doc, out);
|
|
145
156
|
}
|
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.
|
|
2
|
+
export const VERSION = '0.2.2';
|