siteread 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,219 @@
1
+ // The vendors' published crawler IP lists: what tells a real ChatGPT-User from a scanner
2
+ // wearing its User-Agent.
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:
8
+ // 217 of 221 ChatGPT-User requests from outside OpenAI's list came from IPs that also got a 444
9
+ // (nginx's "close the connection, send nothing" answer to a scanner path), and DeepSeekBot /
10
+ // CCBot / Grok / Perplexity-User / Claude-SearchBot were 100 % spoofed. Where a vendor publishes
11
+ // a list, "IP is in it" is the test; where none exists, the fallback is "the IP never hit a
12
+ // scanner path" (the 444 rule: createVerifier's `isKnownScannerIp`). On that site the two tests
13
+ // agreed almost perfectly, so the fallback is honest, just weaker.
14
+ //
15
+ // The 444 rule CANNOT clear a crawler that follows a bad link into a scanner path. ClaudeBot
16
+ // (on the 444 rule until 10-17) resolved a GitHub README's relative links against the site,
17
+ // asked for /protocol//blob/master/examples/docker-compose.yml, drew one 444, and all 50 of
18
+ // that IP's reads that day were counted spoofed. Anthropic publishes ONE list for its bots,
19
+ // claude.com/crawling/bots.json, used here for all three Claude agents: on 30 days of log every
20
+ // ClaudeBot IP and every page-reading Claude-User IP was in it, and the four IPs probing
21
+ // /.env-style paths under Claude names were not. (Claude-User from Anthropic's 160.79.104.0/21
22
+ // was NOT in it: that is the MCP connector calling a tool server, not a page read. The list
23
+ // published 2026-10-07 does carry two /28s from that range.)
24
+ //
25
+ // SHAPE. A list is refetched only when its stored copy is > 24 h old. A failed fetch keeps the
26
+ // previous copy; a copy nobody could refresh for 8 days is dropped, so a vendor's moved ranges
27
+ // cannot quietly mark its new IPs as spoofed forever. Nothing here throws: no store, no network
28
+ // or a broken list all end in "no list for that agent", which is the verdict 'unknown'.
29
+
30
+ import net from 'node:net';
31
+
32
+ export const REFRESH_AFTER_MS = 24 * 3_600_000;
33
+ export const DROP_AFTER_MS = 8 * 86_400_000;
34
+ export const FETCH_TIMEOUT_MS = 8_000;
35
+ // A list is a few hundred prefixes; these bound what a broken (or hostile) response can cost.
36
+ export const MAX_BODY_CHARS = 2_000_000;
37
+ export const MAX_PREFIXES = 20_000;
38
+ // No vendor publishes anything near this broad. A /0 in a list would verify EVERY address,
39
+ // so a prefix shorter than these is a broken list, not a big one.
40
+ export const MIN_PREFIX = Object.freeze({ ipv4: 8, ipv6: 16 });
41
+ // Two processes sharing a store can disagree on the time by a little. A fetchedAt further in
42
+ // the future than this is not skew: it would keep a list "fresh" forever and defeat the drop.
43
+ const CLOCK_SKEW_MS = 5 * 60_000;
44
+
45
+ const CLAUDE_BOTS = 'https://claude.com/crawling/bots.json';
46
+
47
+ // Agent key (agents.js AGENTS / CRAWLERS) → its vendor's published list. Every URL answers
48
+ // { creationTime, prefixes: [{ ipv4Prefix } | { ipv6Prefix }] } (all checked 2026-10-21).
49
+ export const SOURCES = Object.freeze([
50
+ { agent: 'chatgpt-user', url: 'https://openai.com/chatgpt-user.json' },
51
+ { agent: 'oai-searchbot', url: 'https://openai.com/searchbot.json' },
52
+ { agent: 'gptbot', url: 'https://openai.com/gptbot.json' },
53
+ { agent: 'perplexity-user', url: 'https://www.perplexity.com/perplexity-user.json' },
54
+ { agent: 'perplexitybot', url: 'https://www.perplexity.com/perplexitybot.json' },
55
+ { agent: 'applebot', url: 'https://search.developer.apple.com/applebot.json' },
56
+ // Not claude-code: Claude Code's WebFetch runs on the user's own machine, never from these.
57
+ { agent: 'claude-user', url: CLAUDE_BOTS },
58
+ { agent: 'claude-searchbot', url: CLAUDE_BOTS },
59
+ { agent: 'claudebot', url: CLAUDE_BOTS },
60
+ // Classic search. googlebot.json covers Googlebot and its -Image/-News/-Video variants.
61
+ { agent: 'googlebot', url: 'https://developers.google.com/static/search/apis/ipranges/googlebot.json' },
62
+ { agent: 'bingbot', url: 'https://www.bing.com/toolbox/bingbot.json' },
63
+ ].map(Object.freeze));
64
+
65
+ /** 'a.b.c.d/n' or 'x::/n' → { type, net, prefix, cidr } (normalised), or null. */
66
+ export function parseCidr(raw) {
67
+ if (typeof raw !== 'string') return null;
68
+ const s = raw.trim();
69
+ const slash = s.indexOf('/');
70
+ if (slash < 1 || s.length > 64) return null;
71
+ const addr = s.slice(0, slash);
72
+ const bits = s.slice(slash + 1);
73
+ if (!/^\d{1,3}$/.test(bits)) return null;
74
+ const prefix = Number(bits);
75
+ const type = net.isIPv4(addr) ? 'ipv4' : net.isIPv6(addr) ? 'ipv6' : null;
76
+ if (!type) return null;
77
+ if (prefix < MIN_PREFIX[type] || prefix > (type === 'ipv4' ? 32 : 128)) return null;
78
+ return { type, net: addr.toLowerCase(), prefix, cidr: `${addr.toLowerCase()}/${prefix}` };
79
+ }
80
+
81
+ /** The vendors' shared format: { prefixes: [{ ipv4Prefix } | { ipv6Prefix }] } → [cidr]. */
82
+ export function parseList(body) {
83
+ const items = Array.isArray(body?.prefixes) ? body.prefixes : [];
84
+ const out = new Set();
85
+ for (const it of items) {
86
+ if (out.size >= MAX_PREFIXES) break;
87
+ const p = parseCidr(it?.ipv4Prefix ?? it?.ipv6Prefix);
88
+ if (p) out.add(p.cidr);
89
+ }
90
+ return [...out];
91
+ }
92
+
93
+ /** A stored copy's prefixes ([cidr]) → [cidr], re-validated and bounded. */
94
+ export function parsePrefixes(list) {
95
+ if (!Array.isArray(list)) return [];
96
+ 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
+ }
101
+ return [...out];
102
+ }
103
+
104
+ // An address as the log writes it → { ip, type }, or null. An IPv4-mapped IPv6 address
105
+ // (::ffff:1.2.3.4, what a dual-stack listener can log) is checked as the IPv4 it is.
106
+ export function normIp(raw) {
107
+ let s = String(raw ?? '').trim().toLowerCase();
108
+ const zone = s.indexOf('%');
109
+ if (zone !== -1) s = s.slice(0, zone);
110
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s);
111
+ if (mapped) s = mapped[1];
112
+ if (net.isIPv4(s)) return { ip: s, type: 'ipv4' };
113
+ if (net.isIPv6(s)) return { ip: s, type: 'ipv6' };
114
+ return null;
115
+ }
116
+
117
+ /** ms since epoch from a stored fetchedAt, or NaN. */
118
+ export function fetchedAtMs(l) {
119
+ return typeof l?.fetchedAt === 'string' ? Date.parse(l.fetchedAt) : NaN;
120
+ }
121
+
122
+ /** True when a copy fetched at `at` is younger than `maxAgeMs` at `now` (and not from the future). */
123
+ export function youngerThan(at, now, maxAgeMs) {
124
+ return Number.isFinite(at) && now - at < maxAgeMs && at - now <= CLOCK_SKEW_MS;
125
+ }
126
+
127
+ /** { lists: { agent: { prefixes, fetchedAt } } } from whatever a store returned. */
128
+ export function normDoc(doc) {
129
+ const lists = doc && typeof doc === 'object' && doc.lists && typeof doc.lists === 'object' && !Array.isArray(doc.lists)
130
+ ? doc.lists : {};
131
+ // A copy: the caller mutates it, and an in-memory store may hand out its own object.
132
+ const out = {};
133
+ for (const [agent, l] of Object.entries(lists)) {
134
+ if (l && typeof l === 'object' && Array.isArray(l.prefixes)) {
135
+ out[agent] = { prefixes: l.prefixes.slice(0, MAX_PREFIXES), fetchedAt: l.fetchedAt };
136
+ }
137
+ }
138
+ return { lists: out };
139
+ }
140
+
141
+ /**
142
+ * A matcher over a stored document: { hasList(agent), contains(agent, addr), get(agent) }.
143
+ * Only the agents in `sources` are loaded, and a copy past the drop age is ignored even if
144
+ * nobody ran refresh() to delete it.
145
+ */
146
+ export function buildMatcher(doc, { sources = SOURCES, now = Date.now() } = {}) {
147
+ const lists = new Map(); // agent → { block, count, fetchedAt }
148
+ for (const { agent } of sources) {
149
+ if (lists.has(agent)) continue;
150
+ const l = doc?.lists?.[agent];
151
+ if (!l || !Array.isArray(l.prefixes)) continue;
152
+ if (!youngerThan(fetchedAtMs(l), now, DROP_AFTER_MS)) continue;
153
+ const block = new net.BlockList();
154
+ let count = 0;
155
+ for (const c of l.prefixes.slice(0, MAX_PREFIXES)) {
156
+ const p = parseCidr(c);
157
+ if (!p) continue;
158
+ try { block.addSubnet(p.net, p.prefix, p.type); count++; } catch { /* skip */ }
159
+ }
160
+ if (count) lists.set(agent, { block, count, fetchedAt: l.fetchedAt });
161
+ }
162
+ return {
163
+ hasList: (agent) => lists.has(agent),
164
+ /** `addr` is normIp()'s { ip, type } or a raw address string. */
165
+ contains(agent, addr) {
166
+ const l = lists.get(agent);
167
+ const a = l && (typeof addr === 'object' && addr ? addr : normIp(addr));
168
+ if (!a) return false;
169
+ try { return l.block.check(a.ip, a.type); } catch { return false; }
170
+ },
171
+ get: (agent) => {
172
+ const l = lists.get(agent);
173
+ return l ? { prefixes: l.count, fetchedAt: l.fetchedAt } : null;
174
+ },
175
+ };
176
+ }
177
+
178
+ // The body, read at most MAX_BODY_CHARS deep: a hostile endpoint streaming forever is cut off
179
+ // here (and by the fetch timeout) instead of being buffered whole before the size check.
180
+ async function readCapped(res, max = MAX_BODY_CHARS) {
181
+ const reader = res.body?.getReader?.();
182
+ if (!reader) {
183
+ const text = await res.text();
184
+ if (text.length > max) throw new Error('response too large');
185
+ return text;
186
+ }
187
+ const dec = new TextDecoder();
188
+ let text = '';
189
+ for (;;) {
190
+ const { done, value } = await reader.read();
191
+ if (done) break;
192
+ text += dec.decode(value, { stream: true });
193
+ if (text.length > max) {
194
+ await reader.cancel().catch(() => {});
195
+ throw new Error('response too large');
196
+ }
197
+ }
198
+ return text + dec.decode();
199
+ }
200
+
201
+ /** GET a URL and JSON.parse it, bounded in time and size. Throws on any failure. */
202
+ export async function fetchJson(url, fetchImpl, { userAgent } = {}) {
203
+ const headers = { accept: 'application/json' };
204
+ if (userAgent) headers['user-agent'] = userAgent;
205
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers });
206
+ if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`);
207
+ const text = await readCapped(res);
208
+ // A list URL that answers 200 with an HTML page (a moved list, a CDN challenge) must not
209
+ // parse as an empty list: JSON.parse is the check. (Content-Type is no help: OpenAI serves
210
+ // chatgpt-user.json as application/octet-stream.)
211
+ return JSON.parse(text);
212
+ }
213
+
214
+ /** One vendor list → [cidr]. Throws when it holds nothing usable. */
215
+ export async function fetchList(url, fetchImpl, opts) {
216
+ const prefixes = parseList(await fetchJson(url, fetchImpl, opts));
217
+ if (!prefixes.length) throw new Error('no usable prefixes');
218
+ return prefixes;
219
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,56 @@
1
+ // Scanner/exploit request paths: what a vulnerability scanner asks for and a real reader never
2
+ // does. An IP that asks for one of these is a scanner, whatever User-Agent it wears; that is the
3
+ // "444 rule" (nginx answers these with 444, "close, send nothing") the verifier falls back on
4
+ // when a vendor publishes no IP list (createVerifier's `isKnownScannerIp`).
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
8
+ // path that matches (a /install.sh, a Decap CMS /admin/config.yml) passes `allow`.
9
+ //
10
+ // Classify on the REQUEST PATH ONLY, never the User-Agent: a scanner whose UA was literally
11
+ // "http://<site>/wp-admin/install.php?step=1" made the raw-line version flag itself. Feed it the
12
+ // raw request target (nginx's $request_uri: path + query, NOT url-decoded), so the rules below
13
+ // see exactly what the edge saw.
14
+
15
+ export const SCANNER_PATH_RE = new RegExp(
16
+ '(\\/\\.(?!well-known))' + // /.env /.git … but not /.well-known
17
+ '|(\\.(php[0-9]?|phtml|phps|asp|aspx|jsp|jspx|cgi|env|sql|bak|old|backup|swp|ini|sh)(\\?|$))' + // dead tech ext
18
+ '|(^\\/+(wp-|xmlrpc|phpmyadmin|boaform|cgi-bin|HNAP1|SDK\\/webLanguage|' +
19
+ 'apply\\.cgi|tmUnblock\\.cgi|GponForm|\\.git|\\.env|actuator(\\/|$)|_ignition|solr\\/|autodiscover))' +
20
+ // Words that are also ordinary slugs (/mysql-vs-postgres, /backup-strategies, /shell-tips) need
21
+ // a segment end after them; the scanner forms (/mysql/, /backup.zip, /shell?cd+/tmp) still match.
22
+ // (The original matched these as bare prefixes: its site had no such pages.) `/vendor/` is not
23
+ // here at all: static sites serve /vendor/*.js, and the scanner hits below it end in .php or a
24
+ // dotfile, which the rules above already catch.
25
+ '|(^\\/+(wordpress|pma|mysql|shell|backups?)(?=[/?.]|$))' +
26
+ // WP-artifact probes bury these deep in the path (//wp-includes/…/license.txt/…) with a leading
27
+ // // to dodge the ^/-anchored rule; these tokens never appear in a legit URL: match anywhere.
28
+ '|(wp-includes|wp-admin|wp-content|wp-login|wlwmanifest|xmlrpc)' +
29
+ // Percent-encoded dot: /%2eenv, /$(pwd)/%2egit/config, /private/smtp%2ejson. Every rule above
30
+ // wants a LITERAL '.', and the raw request target is not url-decoded, so these passed as
31
+ // ordinary traffic until this rule. Path only: [^?]* stops at the first '?' so an encoded
32
+ // query value can't false-positive.
33
+ '|(^[^?]*%2e)' +
34
+ // Secret/config-file scanners: framework and cloud artifacts no site serves on purpose.
35
+ // docker-compose only as a YAML FILE, so a /docker-compose-tutorial/ post is not a probe (the
36
+ // ClaudeBot incident's /…/examples/docker-compose.yml still is).
37
+ '|(appsettings\\.|aws-exports\\.|composer\\.(json|lock)|/_profiler|/phpinfo|launchSettings|' +
38
+ 'serviceAccountKey|service-account-credentials|api-keys\\.json|docker-compose(\\.[\\w-]+)?\\.ya?ml|' +
39
+ 'config\\.ya?ml|aws\\.ya?ml)' +
40
+ // Dockerfile and credentials.* only as FILES: a route that merely ends in /credentials is a
41
+ // normal API path on plenty of apps.
42
+ '|(/Dockerfile(\\.[a-z]+)?(\\?|$))|(/credentials\\.[a-z]+(\\?|$))',
43
+ 'i',
44
+ );
45
+
46
+ /**
47
+ * True when a raw request target (path + query, not url-decoded) is a scanner probe.
48
+ * `allow` (a RegExp tested against the same string) exempts a site's own legit paths.
49
+ */
50
+ export function isScannerPath(path, { allow } = {}) {
51
+ if (typeof path !== 'string' || !path) return false;
52
+ if (!SCANNER_PATH_RE.test(path)) return false;
53
+ // search() ignores a /g regex's lastIndex, so a shared global `allow` can't flip-flop.
54
+ if (allow instanceof RegExp && path.search(allow) !== -1) return false;
55
+ return true;
56
+ }
@@ -0,0 +1,221 @@
1
+ // A verdict for one request: is the IP that sent this User-Agent really that vendor's crawler?
2
+ //
3
+ // verified the agent's vendor publishes an IP list and the IP is in it
4
+ // spoofed the vendor publishes a list and the IP is not in it (or the UA is a robots.txt
5
+ // token nothing real sends, or `isKnownScannerIp` says the IP probed scanner paths)
6
+ // unknown no list to check against: the vendor publishes none, or it could not be loaded
7
+ // n/a the UA claims to be no known AI agent or search crawler
8
+ //
9
+ // A vendor list always wins over the 444 rule: ClaudeBot once followed a broken README link
10
+ // into a scanner path, and the 444 rule called a real crawler spoofed (ip-ranges.js).
11
+ //
12
+ // Refresh rules are the ported ones (ip-ranges.js): refetch a list when the stored copy is
13
+ // > 24 h old, keep the old copy when a fetch fails, drop a copy nobody could refresh for 8 days.
14
+ // Nothing here throws.
15
+
16
+ import {
17
+ SOURCES, REFRESH_AFTER_MS, DROP_AFTER_MS,
18
+ normIp, normDoc, buildMatcher, fetchJson, fetchList, parsePrefixes, fetchedAtMs, youngerThan,
19
+ } from './ip-ranges.js';
20
+ import { detectAgent, detectCrawler } from './agents.js';
21
+ import { VERSION } from './version.js';
22
+
23
+ const USER_AGENT = `siteread/${VERSION} (+https://www.npmjs.com/package/siteread)`;
24
+
25
+ /** The default store: one process, nothing on disk. */
26
+ export function memoryStore(initial = null) {
27
+ let doc = initial;
28
+ return {
29
+ async get() { return doc; },
30
+ async set(next) { doc = next; },
31
+ };
32
+ }
33
+
34
+ /**
35
+ * @param {object} [opts]
36
+ * @param {Function} [opts.fetch] WHATWG fetch (default globalThis.fetch)
37
+ * @param {{get(): Promise<object|null>, set(doc: object): Promise<void>}} [opts.store]
38
+ * @param {Array<{agent: string, url: string}>} [opts.sources]
39
+ * @param {() => number} [opts.now]
40
+ * @param {{warn?: Function, error?: Function}} [opts.log]
41
+ * @param {string|null} [opts.feedUrl] one merged document instead of the vendor URLs (opt-in)
42
+ * @param {(ip: string) => boolean} [opts.isKnownScannerIp] the 444 rule, for agents with no list
43
+ */
44
+ export function createVerifier({
45
+ fetch: fetchImpl = globalThis.fetch,
46
+ store = memoryStore(),
47
+ sources = SOURCES,
48
+ now = Date.now,
49
+ log = console,
50
+ feedUrl = null,
51
+ isKnownScannerIp = null,
52
+ } = {}) {
53
+ // One entry per agent (the first wins), in the order given.
54
+ const srcs = [];
55
+ const urlOf = new Map();
56
+ for (const s of sources) {
57
+ if (!s || typeof s.agent !== 'string' || typeof s.url !== 'string' || urlOf.has(s.agent)) continue;
58
+ srcs.push({ agent: s.agent, url: s.url });
59
+ urlOf.set(s.agent, s.url);
60
+ }
61
+ let matcher = buildMatcher(null, { sources: srcs, now: now() });
62
+
63
+ async function fetchFromVendors(due, t, doc, out) {
64
+ // One GET per distinct URL: Anthropic's bots.json serves three agents.
65
+ const urls = [...new Set(due.map(d => d.url))];
66
+ const results = await Promise.allSettled(urls.map(u => fetchList(u, fetchImpl, { userAgent: USER_AGENT })));
67
+ const byUrl = new Map(urls.map((u, i) => [u, results[i]]));
68
+ for (const { agent, url } of due) {
69
+ const r = byUrl.get(url);
70
+ if (r.status === 'fulfilled') {
71
+ doc.lists[agent] = { prefixes: r.value, fetchedAt: new Date(t).toISOString() };
72
+ out.fetched.push(agent);
73
+ } else {
74
+ // Keep whatever copy is stored: a vendor's CDN hiccup must not turn every one
75
+ // of its real crawlers into "spoofed" until the next refresh.
76
+ fail(out, agent, r.reason, `${agent} (${url})`);
77
+ }
78
+ }
79
+ }
80
+
81
+ // The future hosted feed: { lists: { agent: { prefixes: [cidr], fetchedAt } } }, one GET.
82
+ // Every prefix is re-validated exactly like a vendor's, and a list keeps the feed's own
83
+ // fetchedAt (when the FEED fetched it from the vendor), so the 24 h / 8 day rules still
84
+ // measure the age of the vendor data, not of the feed hop.
85
+ async function fetchFromFeed(due, t, doc, out) {
86
+ let body;
87
+ try {
88
+ body = await fetchJson(feedUrl, fetchImpl, { userAgent: USER_AGENT });
89
+ } catch (err) {
90
+ for (const { agent } of due) fail(out, agent, err, `feed (${feedUrl})`);
91
+ return;
92
+ }
93
+ const lists = body && typeof body.lists === 'object' && body.lists ? body.lists : {};
94
+ for (const { agent } of due) {
95
+ const l = Object.hasOwn(lists, agent) ? lists[agent] : null;
96
+ const prefixes = parsePrefixes(l?.prefixes);
97
+ // A future fetchedAt is clamped to now: it would otherwise keep the copy fresh forever.
98
+ const at = Math.min(fetchedAtMs(l), t);
99
+ const stored = fetchedAtMs(doc.lists[agent]);
100
+ if (!prefixes.length) { fail(out, agent, new Error('no usable prefixes in feed'), 'feed'); continue; }
101
+ if (!Number.isFinite(at)) { fail(out, agent, new Error('feed list has no fetchedAt'), 'feed'); continue; }
102
+ if (Number.isFinite(stored) && at <= stored) { fail(out, agent, new Error('feed copy is not newer'), 'feed'); continue; }
103
+ doc.lists[agent] = { prefixes, fetchedAt: new Date(at).toISOString() };
104
+ out.fetched.push(agent);
105
+ }
106
+ }
107
+
108
+ function fail(out, agent, err, what) {
109
+ const msg = err?.message || String(err);
110
+ out.failed.push(agent);
111
+ out.errors[agent] = msg;
112
+ log?.warn?.(`[siteread] ${what} failed: ${msg}`);
113
+ }
114
+
115
+ /**
116
+ * One refresh pass: read the store, fetch what is due, drop what is too old, write back.
117
+ * Never throws. Returns { fetched, failed, fresh, dropped, errors } (agent keys; errors maps
118
+ * a failed agent to its reason) and `error` when the store itself failed.
119
+ * `force` refetches every list whatever its age (a failed fetch still keeps the old copy).
120
+ */
121
+ async function refresh({ force = false } = {}) {
122
+ const t = now();
123
+ const out = { fetched: [], failed: [], fresh: [], dropped: [], errors: {} };
124
+ let doc;
125
+ try {
126
+ doc = normDoc(await store.get());
127
+ } catch (err) {
128
+ log?.error?.(`[siteread] store read failed: ${err.message}`);
129
+ out.error = err.message;
130
+ return out;
131
+ }
132
+ try {
133
+ const due = [];
134
+ for (const s of srcs) {
135
+ if (!force && youngerThan(fetchedAtMs(doc.lists[s.agent]), t, REFRESH_AFTER_MS)) out.fresh.push(s.agent);
136
+ else due.push(s);
137
+ }
138
+ if (due.length) {
139
+ if (typeof fetchImpl !== 'function') {
140
+ for (const { agent } of due) fail(out, agent, new Error('no fetch available'), agent);
141
+ } else if (feedUrl) {
142
+ await fetchFromFeed(due, t, doc, out);
143
+ } else {
144
+ await fetchFromVendors(due, t, doc, out);
145
+ }
146
+ }
147
+ for (const [agent, l] of Object.entries(doc.lists)) {
148
+ if (urlOf.has(agent) && youngerThan(fetchedAtMs(l), t, DROP_AFTER_MS)) continue;
149
+ delete doc.lists[agent];
150
+ out.dropped.push(agent);
151
+ }
152
+ if (out.fetched.length || out.dropped.length) await store.set(doc);
153
+ } catch (err) {
154
+ log?.error?.(`[siteread] refresh failed: ${err.message}`);
155
+ out.error = err.message;
156
+ }
157
+ // Whatever made it into `doc` is usable in this process even if the store write failed.
158
+ matcher = buildMatcher(doc, { sources: srcs, now: t });
159
+ return out;
160
+ }
161
+
162
+ /** Rebuild the matcher from the store without fetching (a reader beside one refresher). */
163
+ async function load() {
164
+ const t = now();
165
+ try {
166
+ matcher = buildMatcher(normDoc(await store.get()), { sources: srcs, now: t });
167
+ } catch (err) {
168
+ log?.error?.(`[siteread] store read failed: ${err.message}`);
169
+ }
170
+ return lists();
171
+ }
172
+
173
+ /** The verdict for one request. Synchronous: uses the lists the last refresh()/load() saw. */
174
+ function check(ip, ua) {
175
+ const agent = detectAgent(ua);
176
+ const e = agent || detectCrawler(ua);
177
+ if (!e) {
178
+ return { agent: null, kind: null, assistant: null, purpose: null, verdict: 'n/a', reason: 'not-a-known-agent', source: null };
179
+ }
180
+ const base = {
181
+ agent: e.key,
182
+ kind: agent ? 'ai' : 'crawler',
183
+ assistant: e.assistant ?? null,
184
+ purpose: e.purpose,
185
+ source: urlOf.get(e.key) ?? null,
186
+ };
187
+ if (e.robotsTokenOnly) return { ...base, verdict: 'spoofed', reason: 'robots-token-not-a-user-agent' };
188
+ const addr = normIp(ip);
189
+ if (!addr) return { ...base, verdict: 'unknown', reason: 'invalid-ip' };
190
+ if (matcher.hasList(e.key)) {
191
+ return matcher.contains(e.key, addr)
192
+ ? { ...base, verdict: 'verified', reason: 'in-vendor-list' }
193
+ : { ...base, verdict: 'spoofed', reason: 'not-in-vendor-list' };
194
+ }
195
+ // No list. The 444 rule, if the caller supplies it: an IP that probed scanner paths is not
196
+ // the crawler it claims to be. It can only ever turn 'unknown' into 'spoofed'.
197
+ if (typeof isKnownScannerIp === 'function') {
198
+ let hit = false;
199
+ try { hit = Boolean(isKnownScannerIp(addr.ip)); } catch { hit = false; }
200
+ if (hit) return { ...base, verdict: 'spoofed', reason: 'known-scanner-ip' };
201
+ }
202
+ return { ...base, verdict: 'unknown', reason: base.source ? 'list-unavailable' : 'no-vendor-list' };
203
+ }
204
+
205
+ /** Freshness of every source, in `sources` order. */
206
+ function lists() {
207
+ const t = now();
208
+ return srcs.map(({ agent, url }) => {
209
+ const l = matcher.get(agent);
210
+ return {
211
+ agent,
212
+ url,
213
+ prefixes: l?.prefixes ?? 0,
214
+ fetchedAt: l?.fetchedAt ?? null,
215
+ fresh: l ? youngerThan(Date.parse(l.fetchedAt), t, REFRESH_AFTER_MS) : false,
216
+ };
217
+ });
218
+ }
219
+
220
+ return { refresh, load, check, lists };
221
+ }
package/src/version.js ADDED
@@ -0,0 +1,2 @@
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';