Docs
siteread 0.2.1: a CLI and a library for Node 20 or later, with zero dependencies, plus a skill for Claude Code. Everything here runs on your machine.
Install
npm install siteread # the library
npx siteread --help # the CLI, no install
CLI
check answers one request. The first call fetches the crawler lists into a cache file; later calls
reuse a copy younger than 24 h. The lists come from the siteread feed (one GET instead of nine;
below); --no-feed fetches each from its vendor instead, and any list the feed cannot give comes
from its vendor anyway.
$ npx siteread check 216.73.216.12 "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)"
verified 216.73.216.12 is claudebot (claude, training): in claude.com/crawling/bots.json (38 prefixes, fetched 2026-10-20T20:04:53Z)
$ npx siteread check 203.0.113.9 "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot"
spoofed 203.0.113.9 claims chatgpt-user (chatgpt, answer) but is not in openai.com/chatgpt-user.json (234 prefixes, fetched 2026-10-20T20:04:53Z)
$ npx siteread check 203.0.113.9 "Claude-User (claude-code/2.1.292; +https://support.anthropic.com/)"
unknown 203.0.113.9 claims claude-code (claude, answer): Claude Code's WebFetch runs from the user's own machine, so no vendor list can cover it. If this IP also requested scanner paths in your log, treat it as spoofed
| Exit | Meaning |
|---|---|
0 |
verified: the IP is in the vendor's list for that agent |
2 |
spoofed: the vendor publishes a list and the IP is not in it |
3 |
unknown: no list to check against, or the UA is not a known AI agent or search crawler |
1 |
usage error, or the IP is not an IP address |
lists shows what the cache holds:
$ npx siteread lists
agent prefixes fetched (UTC) source
chatgpt-user 234 2026-10-20T20:04:53Z https://openai.com/chatgpt-user.json
oai-searchbot 39 2026-10-20T20:04:53Z https://openai.com/searchbot.json
gptbot 18 2026-10-20T20:04:53Z https://openai.com/gptbot.json
perplexity-user 4 2026-10-20T20:04:53Z https://www.perplexity.com/perplexity-user.json
perplexitybot 8 2026-10-20T20:04:53Z https://www.perplexity.com/perplexitybot.json
applebot 24 2026-10-20T20:04:53Z https://search.developer.apple.com/applebot.json
claude-user 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
claude-searchbot 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
claudebot 38 2026-10-20T20:04:53Z https://claude.com/crawling/bots.json
googlebot 317 2026-10-20T20:04:53Z https://developers.google.com/static/search/apis/ipranges/googlebot.json
bingbot 28 2026-10-20T20:04:53Z https://www.bing.com/toolbox/bingbot.json
Options: --no-cache refetches every list now (a failed fetch still falls back to the cached copy),
--json prints the whole result. The cache is <os.tmpdir()>/siteread/lists.json; set
SITEREAD_CACHE to put it elsewhere. siteread --help prints all of this.
Digest
digest reads a Search Console export (the Performance → Export → Download CSV zip) and your
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
impression-weighted position, people by first-touch source (an IP that sent a browser UA, loaded
an asset and never probed a scanner path), goal counts, and every crawler checked against its
vendor's list.
$ npx siteread digest --gsc export.zip --log access.log.1.gz --log access.log \
--site example.org --sitemap sitemap.xml --goal 'signup=^POST /signup 2' --out .siteread/digests
.siteread/digests/2026-10-18.json
A JSON log that holds several sites (one nginx serving an API, an app and a blog) is cut to one
with --host, repeated for each name (www. is a different host). Combined lines carry no host,
so --host keeps them and says so. For nginx JSON, the common field names are read
(remote_addr, request_uri or request, status, time/time_iso8601/time_local/msec,
http_user_agent or user_agent, http_referer or referer, host or http_host), with
escape=json or nginx's default escaping.
npx siteread --help lists every option. The seo-read skill for Claude Code, in this project's
skill/ folder, turns the JSON into a weekly memo.
The seo-read skill
A Claude Code skill: give it a Google Search Console export and your access log, and it writes a
dated weekly memo (who read the site, whether it went well, what to do next) into
siteread-log.md. The numbers come from npx siteread digest (zero dependencies, runs locally);
Claude writes the prose.
Install
mkdir -p ~/.claude/skills && cp -r skill/seo-read ~/.claude/skills/
Then, in the folder where you keep the export and logs, ask Claude Code for "my weekly seo read".
Share
share sends one week of a digest's aggregate numbers to the public benchmark and prints where each
number stands among the quartiles the benchmark publishes. It prints the exact bytes first and sends nothing without a yes (a
prompt, or --yes in a script). --dry-run prints and stops.
$ npx siteread share --dry-run
siteread: the whole payload from .siteread/digests/2026-10-18.json, byte for byte, for https://api.siteread.dev/v1/share:
{
"schema": 1,
"id": "3803e566762f2e56ba381b993ddf466e",
"week": "2026-W42",
"siteread": "0.2.1",
"metrics": {
"people": 4,
"peopleFromSearch": 1,
"peopleFromAi": 0,
"peopleFromSocial": 2,
"aiAnswerFetches": 0,
"aiSearchFetches": 0,
"aiTrainingFetches": 2,
"searchEngineFetches": 8,
"aiSpoofedPct": 25,
"gscImpressions": 238,
"gscClicks": 3,
"gscCtrPct": 1.3,
"gscPosition": 17.9
},
"proof": "2746561"
}
siteread: not sent (--dry-run)
The answer places each number in a band among the published 25th, 50th and 75th percentiles, and only for numbers the benchmark page already shows a spread for; for the rest it gives the count of sites so far. It is read from the published benchmark (rebuilt hourly), not from other sites' rows, so it never tells you more than the page does. With 14 anonymous sites, 9 of them with Search Console numbers (made up for this example):
sent 2026-W42. Against the benchmark built 2026-10-21 09:00 UTC: 14 anonymous sites, 2026-W39 to 2026-W42
metric yours where
people 4 below the 25th percentile
peopleFromAi 0 25th to 50th percentile
gscImpressions 238 9 sites; shown from 10
gscPosition 17.9 9 sites; shown from 10
…
- What is sent: the numbers above for the digest's last 7 days (
aiSpoofedPctis over the whole log), a week label, the version, and a random id.SHARE_METRICSdefines each one. - What is never sent: hostnames, URLs or paths, visitor IPs, user-agents, query text, goal labels. The payload is built from an allowlist of numbers, and the endpoint refuses anything else.
- The id is 128 random bits in
.siteread/share-id, made on first use. It makes the benchmark count sites rather than shares, and a second share of the same week replaces the first. Delete the file to start over as a new site. - The proof is a proof of work tied to the id: a number that makes SHA-256 of the id start with
22 zero bits, a few seconds of CPU once, kept in
.siteread/share-proof. Only a site's first share needs it. Ids are free to make, so without it one sender could invent a flood of sites; the service never reads an address, so it makes each new site cost time instead, and the price rises by one bit for every 50 new sites in a day. On a busy day the service asks for more; the CLI makes it and tells you to runshareagain. - A part with less than a full week (a 4-day log, an export whose last week is cut short) is left
out and the CLI says so.
--endpoint(orSITEREAD_SHARE_URL) sends elsewhere:https://only, plainhttp://only to localhost.
In the library, sharePayload(digest, { id }) returns { payload, notes }, and validateShare(body)
is the endpoint's check.
Library
import { createVerifier, isScannerPath } from 'siteread';
const verifier = createVerifier();
await verifier.refresh(); // fetch the lists; call again every few hours
verifier.check('216.73.216.12', 'Mozilla/5.0 … ClaudeBot/1.0; +claudebot@anthropic.com)');
// {
// agent: 'claudebot', kind: 'ai', assistant: 'claude', purpose: 'training',
// source: 'https://claude.com/crawling/bots.json', verdict: 'verified', reason: 'in-vendor-list'
// }
createVerifier(options)
| Option | Default | |
|---|---|---|
fetch |
globalThis.fetch |
any WHATWG fetch |
store |
in memory | { get(): Promise<doc | null>, set(doc): Promise<void> }; fileStore(path) keeps it in a JSON file |
sources |
SOURCES |
[{ agent, url }], which lists to fetch |
now |
Date.now |
a clock, for tests |
log |
console |
{ warn, error }; pass {} for silence |
isKnownScannerIp |
none | (ip) => boolean, the 444 rule (below) |
feedUrl |
none | one merged document instead of the vendor URLs (below); FEED_URL is the hosted one |
fallback |
false |
with feedUrl: fetch from the vendors what the feed could not give |
It returns:
refresh({ force })reads the store, fetches every list that is due, drops what is too old and writes back. It never throws and returns{ fetched, failed, fresh, dropped, errors }(agent keys;errorsmaps a failed agent to its reason).forcerefetches lists that are still fresh.check(ip, ua)is synchronous and uses the lists the lastrefresh()orload()saw. It returns{ agent, kind, assistant, purpose, source, verdict, reason }:verdict reason when verifiedin-vendor-listthe IP is in the list for the agent the UA claims spoofednot-in-vendor-listthat agent has a list and the IP is not in it spoofedrobots-token-not-a-user-agentthe UA says Google-Extended (a robots.txt token, never sent as a UA) spoofedknown-scanner-ipno list, and your isKnownScannerIp(ip)said yesunknownno-vendor-listthe operator publishes no list (CCBot, YandexBot, Claude Code, …) unknownlist-unavailableit has one, but it could not be loaded and there is no copy younger than 8 days unknowninvalid-ipthe IP did not parse n/anot-a-known-agentthe UA claims to be no known AI agent or search crawler ( agent: null)kindis'ai'(anAGENTSentry) or'crawler'(aCRAWLERSentry). An IPv4-mapped IPv6 address (::ffff:1.2.3.4, as a dual-stack listener logs it) is checked as the IPv4 it is.load()rebuilds the matcher from the store without fetching: for workers that share a store with one process that refreshes.lists()returns[{ agent, url, prefixes, fetchedAt, fresh }]insourcesorder.
The 444 rule, for agents without a list
Most operators publish no IP list. For those, siteread says unknown rather than guess. If you
know which IPs probed your site for scanner paths, pass that and an unknown becomes spoofed:
import { readFileSync } from 'node:fs';
import { createVerifier, fileStore, isScannerPath } from 'siteread';
const lines = readFileSync('access.log', 'utf8').trim().split('\n').map(JSON.parse); // your log format here
const scanners = new Set(lines.filter(l => isScannerPath(l.request_uri)).map(l => l.remote_addr));
const verifier = createVerifier({ store: fileStore('.siteread.json'), isKnownScannerIp: ip => scanners.has(ip) });
await verifier.refresh();
for (const l of lines) {
const r = verifier.check(l.remote_addr, l.http_user_agent);
if (r.agent) console.log(r.verdict, r.agent, l.request_uri);
}
The hook only ever turns unknown into spoofed. When the agent has a vendor list, the list wins,
even for an IP that hit a scanner path. The ClaudeBot story below is why.
UA tables and scanner paths
AGENTSlists 23 AI agents as{ key, re, assistant, purpose }, wherepurposeisanswer(fetched live for a person),search(an assistant's index) ortraining. The FIRST match wins.detectAgent(ua)returns the entry ornull.CRAWLERSlists classic search and SEO crawlers (Googlebot, bingbot, YandexBot, DuckDuckBot, Slurp, PetalBot, AhrefsBot, SemrushBot, MJ12bot, facebookexternalhit).detectCrawler(ua)returns the entry, andisSearchCrawler(ua)returns a boolean. AI agents are never search crawlers here: Applebot, GPTBot and the rest are inAGENTS.isScannerPath(path, { allow })is true for a vulnerability-scanner request target: dotfiles (but not/.well-known/),.php/.asp/.jsp/.env/.sql/.bak/.sh…,wp-*, phpMyAdmin,cgi-bin, router exploits, percent-encoded dots (/%2eenv), secret and config file names,Dockerfile. Pass the raw request target (path + query, not URL-decoded: nginx's$request_uri). If a real path of yours matches, exempt it:isScannerPath(p, { allow: /^\/(install\.sh|admin\/config\.yml)(\?|$)/ }).parseCidr,parseList,normIpandSOURCESare exported for tests and extensions.
Vendor lists
| Agent(s) | List |
|---|---|
| chatgpt-user | https://openai.com/chatgpt-user.json |
| oai-searchbot | https://openai.com/searchbot.json |
| gptbot | https://openai.com/gptbot.json |
| perplexity-user | https://www.perplexity.com/perplexity-user.json |
| perplexitybot | https://www.perplexity.com/perplexitybot.json |
| applebot | https://search.developer.apple.com/applebot.json |
| claude-user, claude-searchbot, claudebot | https://claude.com/crawling/bots.json (fetched once for all three) |
| googlebot | https://developers.google.com/static/search/apis/ipranges/googlebot.json |
| bingbot | https://www.bing.com/toolbox/bingbot.json |
All of them answer { "creationTime": …, "prefixes": [{ "ipv4Prefix": … } | { "ipv6Prefix": … }] }.
- Refresh: a list is refetched only when the stored copy is more than 24 h old.
- Failure: a failed fetch keeps the previous copy, so a vendor's CDN hiccup does not turn its real
crawlers into
spoofed. A 200 that is not JSON (an HTML challenge page, a moved list) counts as a failure, not as an empty list. - Drop: a copy nobody could refresh for 8 days is dropped, so a vendor's moved ranges cannot mark
its new IPs as spoofed forever. With no copy, the verdict is
unknown, neverspoofed. - Bounds: a body over 2,000,000 characters, more than 20,000 prefixes, or a prefix broader than
/8 (IPv4) or /16 (IPv6) is rejected. A
/0would verify every address.
The feed
{ "siteread": "0.2.1", "generatedAt": "2026-10-21T17:28:48.171Z",
"lists": { "claudebot": { "prefixes": ["216.73.216.0/22", "…"], "fetchedAt": "2026-10-21T17:28:46.433Z",
"source": "https://claude.com/crawling/bots.json" }, "…": {} } }
It is built by the package's verifier, so it keeps the package's rules: a failed vendor fetch keeps
the previous copy with its old fetchedAt, a copy nobody could refresh for 8 days is dropped, every
prefix is bounded and re-validated. fetchedAt is when the feed got the list from the vendor, and
the client keeps that time, so its 24 h / 8 day rules measure the vendor data's age, not the hop.
Every run refetches: a client takes a feed copy only when it is newer than its own.
Why
This code was built to count AI traffic on one small site's nginx log, and every simple approach gave the wrong answer.
Scanners wear AI user-agents. One scanner cycles through them. In 30 days of the log (to 2026-10-16), 217 of the 221 ChatGPT-User requests from outside OpenAI's list came from IPs that also drew a 444 (nginx's "close, send nothing" reply to a scanner path). Every request claiming to be DeepSeekBot, CCBot, Grok, Perplexity-User or Claude-SearchBot was fake. A UA count is a count of what scanners chose to say.
The 444 rule cannot clear a real crawler that follows a bad link. Before Anthropic's list was
used, the test was "this IP never hit a scanner path." Then ClaudeBot resolved a GitHub README's
relative links against the site and asked for /protocol//blob/master/examples/docker-compose.yml.
It drew one 444, and all 50 of that IP's reads that day were counted as spoofed. Anthropic publishes
one list for its bots (claude.com/crawling/bots.json). In those 30 days every ClaudeBot IP and
every page-reading Claude-User IP was in it, and the four IPs probing /.env-style paths under
Claude names were not. Hence the rule: where a list exists, it decides.
Claude Code is not Anthropic's crawler. Claude Code's WebFetch sends
Claude-User (claude-code/…) from the user's own machine and IP, so no list can cover it. It is
matched before Claude-User and always comes back unknown.
Google-Extended is not a User-Agent. It is a robots.txt token that Google's crawlers obey while fetching as Googlebot. A request that sends it as a UA is not from Google.