robotsmith

Verifies a robots.txt — and advises how to write it starting from the traffic your site actually receives.

It comes out of a concrete problem: some crawlers were taking 5% of a site's requests without bringing a single visit, and the question «what do I put in robots.txt?» had no data-backed answer. The lists you find online are generic; a site's traffic is specific.

Install

# Homebrew — macOS and Linuxbrew. Homebrew 6+ asks to trust a third-party tap the first
# time: brew trust --cask Allan-Nava/tap/robotsmith
brew install --cask Allan-Nava/tap/robotsmith

# Docker — scratch image, runs as nobody
docker run --rm ghcr.io/allan-nava/robotsmith check example.com
docker run --rm -v "$PWD:/w:ro" ghcr.io/allan-nava/robotsmith lint /w/robots.txt

# Go
go install github.com/Allan-Nava/robotsmith@latest

Go 1.24, no external dependencies — a single static binary, fit for a CI pipeline. Prebuilt binaries for linux and macOS (amd64/arm64) with checksums come with every release; the container image is scratch plus the binary and a CA bundle, with no shell and no package manager inside it.

check

Is the file crawlers actually see the one you wrote? Fetches it, compares it with the origin, runs 32 expected cases.

lint

Does the file say what you think it says? Finds the structural defects that make it behave differently from its author's intent.

advise

What should you write, given your logs? Classifies the observed crawlers and generates the file, explaining every line. --diff reviews what would change instead.

crawlers

What opinion does this tool hold? Prints the whole classification table in evaluation order — family, policy, the token it would write, and why.

Why writing four lines by hand is not enough

The risk is asymmetric. Blocking a scraper produces no visible effect; accidentally blocking Googlebot drops the site out of the index within weeks — and you notice once the traffic is already gone. So a robots.txt has to be verified, and the file can be right and still not work:

PitfallWhat happens
File served 200 with 0 bytesthat is not «everything forbidden», it is everything allowed
A blank line inside a groupit closes the record: every rule after it is orphaned and a strict parser ignores it
Allow: / before the Disallow ruleswith a first-match parser it voids every prohibition
Change stuck in the CDN cachethe file is correct on the origin and crawlers still see the old one
Sitemap: on another hosta cross-domain sitemap is not considered

⚠️ A cache-buster does not help you find that last one: if the query string is not part of the cache key — the normal setup for a static file — even ?cb=123 returns the old copy. The only reliable comparison is public against origin, which is what --origin does.

The algorithm that advises the file

advise does not apply a canned list: it reads the traffic and decides case by case. For every observed crawler it asks one question — does this traffic bring me anything? — and the answer determines the policy.

log / UA counts │ ▼ ① CLASSIFY ordered table of pattern → family │ ⚠️ order matters: "Googlebot" contains "bot" ▼ ② APPLY THE POLICY per family: │ search, social → ALLOW (they bring visits) │ ai-user → ALLOW (fetch triggered by a person) │ ai-training, seo → BLOCK (they take without giving) │ tool, app, browser → IGNORE (robots.txt does not concern them) │ unknown + heavy → REVIEW (a person decides) ▼ ③ SORT BY VOLUME blocking the one doing 5% beats blocking ten doing 0.1% │ ▼ ④ GENERATE THE FILE explicit allowlist, then the blocks, then the EXISTING RULES unchanged │ never `Allow: /` before the prohibitions · never blank lines in a group ▼ ⑤ STATE THE LIMITS what robots.txt cannot stop, and how much traffic it cannot judge

The non-obvious choices, and why

Reviewing the advice, not re-reading the file

Applying advice is a decision someone has to sign off on, and the risk is asymmetric: nobody notices a wrongly blocked scraper, everybody notices a wrongly blocked Googlebot. So --diff answers the reviewer's actual question — what would change? — instead of handing over a second sixty-line file to compare by eye.

$ robotsmith advise --log access.log --current ./robots.txt --diff
+ ChatGPT-User           Allow: /       13.02%  fetch triggered by a person: blocking it costs visibility, not load
! Googlebot              Allow: /       11.20%  brings visits: blocking it costs real traffic
                         ⚠️  the file currently says: Disallow: /
+ Bytespider             Disallow: /     9.13%  takes content for training without bringing visits
+ YisouSpider            Disallow: /    18.20%  unrecognised but heavy crawler (18.2%): to be reviewed
~ YandexBot              Disallow: /search       written for this site, kept verbatim: this tool does not know why it is there

+ added   ! the file says the opposite   ~ kept verbatim (not advised here)

The ~ line matters as much as the others: a group written by a person for a crawler the logs never showed is carried over verbatim and named out loud, because silence is how a rule gets lost.

Example

$ robotsmith advise --ua-counts ua.txt --current https://example.com/robots.txt --host example.com
Observed 182761 requests from 60 distinct user-agents.

What I advise, and why:
  ALLOW    ChatGPT-User             4.49%  fetch triggered by a person: blocking it costs visibility, not load
  ALLOW    Googlebot                3.86%  brings visits: blocking it costs real traffic
  BLOCK    Bytespider               3.15%  takes content for training without bringing visits
  REVIEW   YisouSpider              6.27%  unrecognised but heavy crawler (6.3%): to be reviewed

The advised blocks touch 11.4% of the observed requests.

⚠️ 16.0% of the traffic declares a browser User-Agent: this command cannot tell whether there are
   people or disguised scrapers behind it, because it does not look at the per-IP rate.

The file itself goes to stdout, every message to stderr — so robotsmith advise … > robots.txt produces a clean file:

# robots.txt generated by robotsmith (github.com/Allan-Nava/robotsmith)
# Based on 62,970 observed requests. The advised blocks touch 28.8% of them.
#
# ⚠️ robots.txt is a REQUEST, not a control: whoever disguises itself as a browser ignores it.
#    For those you need a request cap or a WAF.

# ── Allowed on purpose: they bring visits ───────────────────────────
# ai-user — fetch triggered by a person: blocking it costs visibility, not load
User-agent: ChatGPT-User
Allow: /

# ── Blocked: they take content without bringing visits ──────────────
User-agent: Bytespider
Disallow: /
# ⚠️ Unrecognised but heavy crawler: 18.2% of the requests (11,460).
User-agent: YisouSpider
Disallow: /

# ── General rules ───────────────────────────────────────────────────
User-agent: *
Disallow: /admin/
# ⚠️ The lines below were ORPHANED in the previous file (after a blank line
#    inside the group): a strict parser ignored them. Recovered here.
Disallow: /checkout/

Sitemap: https://example.com/sitemap.xml

Longest match, not first match

The parser is written in-house on purpose. The historical implementations (including Python's stdlib robotparser) apply the first matching rule; RFC 9309 — and Google — use the longest match, with Allow winning ties:

User-agent: *
Allow: /
Disallow: /login

/login comes out allowed under first-match and disallowed under the RFC. A tool that advises what to write has to model how real crawlers behave, so it implements the second one — and lint still flags that layout, because not every crawler is compliant.

Usage

# verify: the file is there, it is fresh, it says the right thing (32 cases)
robotsmith check example.com --origin https://internal.origin/robots.txt

# structural defects of a local or remote file
robotsmith lint ./robots.txt

# advice from the logs (HAProxy or nginx), preserving the current rules
robotsmith advise --log access.log \
  --current https://example.com/robots.txt --host example.com --out robots.txt

# rotated logs, straight off the pipe — gzip is detected by content, not by file name
zcat access.log.*.gz | robotsmith advise --log - --host example.com

# or from a count you already have
awk '{n=split($0,q,"\""); if(n>=5) print q[4]}' access.log | sort | uniq -c | sort -rn > ua.txt
robotsmith advise --ua-counts ua.txt

Flags

CommandFlagWhat it does
check--origin <url>compares the public copy with the origin — the only reliable way to catch a stale CDN copy
check--path <path>the path the expected cases are evaluated against (default /)
check--sitemapsask every Sitemap: URL whether it answers — off by default, because a verification must not make network calls nobody asked for
check--quietprint the verdict only
lint--strictmake warnings fail too, for a file that must be correct under a first-match parser as well
advise--log <file|->access log (HAProxy or nginx); - reads stdin, gzipped input is decompressed transparently
advise--ua-counts <file>a count already made: the output of … | sort | uniq -c
advise--current <file|url>the current robots.txt, whose rules are preserved verbatim
advise--host <host>the site's host, used to validate the Sitemap: line
advise--out <file>write the advised file there instead of stdout
advise--diffreview what would change against --current, instead of printing the whole file
all three--jsonemit a machine-readable document on stdout

Machine-readable output

--json puts one document on stdout and nothing else there; the exit code is unchanged. Consumers pin the schema field, which is the only stable promise — the prose is free to be reworded, and a document is never edited in place: a breaking change bumps its number.

CommandSchemaCarries
checkrobotsmith.check/1cases, failures, deindexing flag, problems, structural findings, cache headers
lintrobotsmith.lint/1findings with severity and line — what a CI annotation needs
adviserobotsmith.advise/1decisions (family, policy, share, reason, and — from a log — the paths and time span behind them), warnings and the advised file
crawlersrobotsmith.crawlers/1the whole classification table, in evaluation order
robotsmith lint ./robots.txt --json | jq -r '.findings[] | "::error line=\(.line)::\(.message)"'
robotsmith advise --ua-counts ua.txt --json | jq -r '.decisions[] | select(.policy=="block") | .name'

Exit codes

0everything as it should be
1something is not
2usage error
4file unreachable

They are a contract: in CI a robots.txt that loses rules or stays stuck in a cache becomes a red build instead of a late discovery. Telling 1 from 4 matters — "the file says the wrong thing" and "the file is not there" call for different actions.

⛔ What this tool does not do

How it is built

Everything is derived from a test. Development is test-first: every behaviour change starts from a failing test, and every bug found gets a test before the fix. The suite runs with -race in CI along with gofmt -l, go vet and go build; no test touches the network (httptest only) or writes outside a temporary directory.

It eats its own cooking. This site publishes its own robots.txt, held to robotsmith's standard by robotsmith's tests on every run — clean lint, all 32 expected cases — and a weekly job runs the tool against the published site. One thing that file says out loud, because it is the trap in miniature: crawlers read robots.txt only from the host root, so on a GitHub project page a file at /<repo>/robots.txt is correct, present, and governs nothing. The copy here is an example plus a Sitemap: pointer; the file that actually speaks for this page is the one at the host root.

The backlog is projected, not retyped. The todos live in one file next to the code (BACKLOG.md), and a sync opens, updates, reopens or closes one GitHub issue per item — matched by a stable id carried in the issue body, so editing a title updates the issue instead of opening a twin. Dry run is the default, and the sync refuses to run on a file that does not lint.

The automation is part of the contract. A tagged push cross-compiles four targets with the version stamped in from the tag, takes the release notes from the CHANGELOG (and refuses to publish if the section is missing), and ships checksums. CI cross-compiles those same targets on every pull request, and a test asserts that the flags and exit codes written on this page are the ones the binary really exposes — the documentation cannot drift without turning the build red. The same gate checks that the Go version agrees across go.mod, the workflows and the Dockerfile, and that every install method promised here has a file behind it.

The parser is kept separate from the policy on purpose: matching correctness is verifiable against the RFC, policy is an opinion. internal/matcher knows nothing about "good crawlers"; internal/advise does no I/O.