robots-audit 1.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +64 -0
- data/robots_audit.rb +195 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: baf8c54332377b0c267c6916e0d282dc4c099d6e631c4c4ae38672d6c337c86f
|
|
4
|
+
data.tar.gz: 612fd78f06f3d4e914a6a217dcee629f64eb650b7c58205b4049f857ef665cc1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 75377ea8d2412c09ffb33990e77a1dfa18db5248815f71a7616c90a9d8940ab1d0ae4aa02b601e84ab1be3ab3979fbc6d7e11103e4db099a2d5c403e7272a31b
|
|
7
|
+
data.tar.gz: 94752665717c851333d756964fc1244608e86fad06d6c5161fd45e44691a23bab4b9df24f9a0efe067ec941f512a99bf08710054b2231f9cb8ac0ff070d55164
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-10-17
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Fetch a site's robots.txt with redirect following (301/302, bounded) and explicit 404/status handling
|
|
8
|
+
- Robots exclusion protocol parsing: User-agent groups (including multi-agent blocks), Disallow rules, Sitemap lines, comment/blank-line handling, UTF-8 BOM stripping, encoding repair
|
|
9
|
+
- Findings: parse violations, orphan Disallow rules, missing wildcard User-agent coverage, missing/present sitemap declarations, unexpected HTTP status, oversize-file truncation warning, many-agents advisory
|
|
10
|
+
- CLI: multi-URL audits, --json output, -T timeout, -v version
|
|
11
|
+
- Exit codes: 0 clean/info, 1 warnings or fetch errors
|
|
12
|
+
- Zero runtime dependencies (stdlib net/http, uri, optparse, json)
|
|
13
|
+
- Test suite: 10 unit tests + 4 e2e cases (local socket servers + live HTTPS round-trips)
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeveloApps
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# robots-audit
|
|
2
|
+
|
|
3
|
+
Audit any website's `robots.txt` from the command line: parse checks, wildcard/sitemap coverage, redirect handling, JSON output. Zero runtime dependencies — Ruby standard library only.
|
|
4
|
+
|
|
5
|
+
## What it checks
|
|
6
|
+
|
|
7
|
+
- **Parse violations** — lines that don't follow the `field: token` robots exclusion protocol structure, orphan `Disallow` rules with no preceding `User-agent`, and UTF-8 BOM or encoding issues.
|
|
8
|
+
- **Wildcard coverage** — whether a `User-agent: *` group exists (if not, your rules only bind to named bots).
|
|
9
|
+
- **Sitemap declarations** — whether `Sitemap:` lines exist and what they point to.
|
|
10
|
+
- **Redirect handling** — follows 301/302 redirects (e.g. `www.` → apex) before auditing, up to a safe limit.
|
|
11
|
+
- **HTTP sanity** — reports missing (404), unexpected, or error statuses explicitly instead of crashing.
|
|
12
|
+
- **Oversize files** — warns when a `robots.txt` exceeds 2 MiB and audits the truncated prefix.
|
|
13
|
+
- **Multi-agent groups** — correct handling of consecutive `User-agent` lines sharing one rule block.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
gem install robots-audit
|
|
18
|
+
|
|
19
|
+
Or run directly from a checkout:
|
|
20
|
+
|
|
21
|
+
ruby robots_audit.rb https://example.com
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
# Human-readable report for one or more sites
|
|
26
|
+
robots_audit.rb https://example.com https://another-site.org
|
|
27
|
+
|
|
28
|
+
# Machine-readable output for CI
|
|
29
|
+
robots_audit.rb --json https://example.com
|
|
30
|
+
|
|
31
|
+
# Custom HTTP timeout (seconds)
|
|
32
|
+
robots_audit.rb -T 30 https://slow-site.example
|
|
33
|
+
|
|
34
|
+
Exit codes: `0` = clean or info-only findings, `1` = warnings present or fetch errors, usage errors abort with a message.
|
|
35
|
+
|
|
36
|
+
Example output:
|
|
37
|
+
|
|
38
|
+
https://example.com -> https://example.com/robots.txt
|
|
39
|
+
[WARN] no-wildcard-agent: No '*' (wildcard) User-agent group — rules only bind to named bots.
|
|
40
|
+
[INFO] no-sitemap: No Sitemap: line — declaring one helps crawlers discover URLs.
|
|
41
|
+
2 finding(s).
|
|
42
|
+
|
|
43
|
+
## Testing
|
|
44
|
+
|
|
45
|
+
The test suite uses plain Ruby assertions (no minitest/rspec dependency):
|
|
46
|
+
|
|
47
|
+
ruby test_robots_audit.rb # 10 unit tests
|
|
48
|
+
ruby e2e_live_check.rb # 4 end-to-end cases (local socket servers + live HTTPS)
|
|
49
|
+
|
|
50
|
+
## Why
|
|
51
|
+
|
|
52
|
+
Small SEO and site-hygiene teams often ship a broken or stale `robots.txt` without noticing — an orphan `Disallow` from an old site structure, a missing wildcard group, a sitemap nobody declared. This gem makes that a one-liner you can run in CI or on demand, with no dependency chain to maintain.
|
|
53
|
+
|
|
54
|
+
## Author
|
|
55
|
+
|
|
56
|
+
Built by [TechEMC](https://techemc.com/) — one controlled workflow with a clear owner and measure. Practical, measurable, controlled.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT — see LICENSE.txt.
|
|
61
|
+
|
|
62
|
+
## Changelog
|
|
63
|
+
|
|
64
|
+
See CHANGELOG.md.
|
data/robots_audit.rb
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# robots-audit — audit robots.txt files against the robots exclusion protocol.
|
|
3
|
+
# stdlib only: net/http, uri, optparse, json. Ruby >= 2.7 (tested 3.4).
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'optparse'
|
|
7
|
+
require 'json'
|
|
8
|
+
|
|
9
|
+
module RobotsAudit
|
|
10
|
+
VERSION = '1.0.0'
|
|
11
|
+
TOKEN = /\A([A-Za-z_-]+):(.*)\z/.freeze
|
|
12
|
+
|
|
13
|
+
Rule = Struct.new(:agent, :path, :directive, :line_no)
|
|
14
|
+
|
|
15
|
+
class Ruleset
|
|
16
|
+
attr_reader :agents, :rules, :sitemaps, :raw_lines
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@agents = []
|
|
20
|
+
@rules = []
|
|
21
|
+
@sitemaps = []
|
|
22
|
+
@raw_lines = []
|
|
23
|
+
@last_agents = []
|
|
24
|
+
@violations = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_line(idx, text)
|
|
28
|
+
text = text.sub(/\A\uFEFF/, '')
|
|
29
|
+
@raw_lines << [idx, text]
|
|
30
|
+
stripped = text.strip
|
|
31
|
+
return if stripped.empty? || stripped.start_with?('#')
|
|
32
|
+
|
|
33
|
+
m = TOKEN.match(stripped)
|
|
34
|
+
return record_violation(idx, stripped, 'unparseable line (no field:token structure)') unless m
|
|
35
|
+
|
|
36
|
+
field = m[1].downcase
|
|
37
|
+
value = m[2].strip
|
|
38
|
+
case field
|
|
39
|
+
when 'user-agent'
|
|
40
|
+
@last_agents << value
|
|
41
|
+
@agents << value unless @agents.include?(value)
|
|
42
|
+
when 'allow', 'disallow'
|
|
43
|
+
record_violation(idx, stripped, "'#{field}' rule without a preceding User-agent") if @last_agents.empty?
|
|
44
|
+
return if field == 'allow'
|
|
45
|
+
@rules << Rule.new(@last_agents.dup, value, field, idx)
|
|
46
|
+
when 'sitemap'
|
|
47
|
+
@sitemaps << value unless @sitemaps.include?(value)
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError
|
|
50
|
+
record_violation(idx, stripped, 'internal parse failure')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def record_violation(idx, text, reason)
|
|
54
|
+
@violations ||= []
|
|
55
|
+
@violations << { line: idx, text: text, reason: reason }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def violations
|
|
59
|
+
@violations || []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def crawl_delay(agent)
|
|
63
|
+
# crawl-delay is a non-standard extension; report presence, don't interpret
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class Fetcher
|
|
69
|
+
USER_AGENT = 'robots-audit/1.0 (+https://rubygems.org/gems/robots-audit)'
|
|
70
|
+
|
|
71
|
+
def self.fetch(url, limit: 5, timeout: 15)
|
|
72
|
+
uri = URI.parse(url)
|
|
73
|
+
raise ArgumentError, "URL must be http(s), got #{uri.scheme.inspect}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
74
|
+
raise ArgumentError, 'URL must be absolute (scheme + host)' if uri.host.to_s.empty?
|
|
75
|
+
|
|
76
|
+
redirects = 0
|
|
77
|
+
res = nil
|
|
78
|
+
robots_url = uri
|
|
79
|
+
loop do
|
|
80
|
+
robots_url = URI.join(robots_url, '/robots.txt')
|
|
81
|
+
http = Net::HTTP.new(robots_url.host, robots_url.port)
|
|
82
|
+
http.use_ssl = (robots_url.scheme == 'https')
|
|
83
|
+
http.open_timeout = timeout
|
|
84
|
+
http.read_timeout = timeout
|
|
85
|
+
res = http.get(robots_url.path.empty? ? '/robots.txt' : robots_url.path)
|
|
86
|
+
break unless res.is_a?(Net::HTTPRedirection)
|
|
87
|
+
raise 'too many redirects' if (redirects += 1) > limit
|
|
88
|
+
robots_url = URI.join(robots_url, res['location'])
|
|
89
|
+
end
|
|
90
|
+
[robots_url, res]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Audit
|
|
95
|
+
MAX_BYTES = 2 * 1024 * 1024
|
|
96
|
+
SANE_AGENT_COUNT = 25
|
|
97
|
+
SANE_RULE_COUNT = 2_000
|
|
98
|
+
|
|
99
|
+
def initialize(uri, res, body)
|
|
100
|
+
@uri = uri
|
|
101
|
+
@res = res
|
|
102
|
+
@body = body.to_s.byteslice(0, MAX_BYTES)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run
|
|
106
|
+
findings = []
|
|
107
|
+
status = @res.code.to_i
|
|
108
|
+
if status == 404
|
|
109
|
+
findings << { severity: 'info', code: 'no-robots', message: "No robots.txt (HTTP 404) — all paths crawlable by default." }
|
|
110
|
+
return findings
|
|
111
|
+
elsif status != 200
|
|
112
|
+
findings << { severity: 'warn', code: 'unexpected-status', message: "robots.txt returned HTTP #{status}." }
|
|
113
|
+
return findings
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if @body.bytesize >= MAX_BYTES
|
|
117
|
+
findings << { severity: 'warn', code: 'oversize-truncated', message: "robots.txt exceeds #{MAX_BYTES} bytes; audit ran on the truncated prefix only." }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
rs = Ruleset.new
|
|
121
|
+
body = @body.dup.force_encoding(Encoding::UTF_8)
|
|
122
|
+
body = body.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
123
|
+
body.lines.each_with_index { |line, i| rs.add_line(i + 1, line) }
|
|
124
|
+
|
|
125
|
+
findings.concat(rule_issues(rs))
|
|
126
|
+
findings
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def rule_issues(rs)
|
|
130
|
+
findings = []
|
|
131
|
+
findings << { severity: 'warn', code: 'empty-file', message: 'robots.txt is present but empty.' } if rs.raw_lines.all? { |_, t| t.strip.empty? || t.strip.start_with?('#') }
|
|
132
|
+
|
|
133
|
+
viols = rs.violations
|
|
134
|
+
viols.first(20).each do |v|
|
|
135
|
+
findings << { severity: 'warn', code: 'parse-violation', message: "Line #{v[:line]}: #{v[:reason]} — \"#{v[:text].byteslice(0, 80)}\"" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if rs.agents.size > SANE_AGENT_COUNT
|
|
139
|
+
findings << { severity: 'info', code: 'many-agents', message: "#{rs.agents.size} User-agent groups — fine, but consider grouping identical rule sets." }
|
|
140
|
+
end
|
|
141
|
+
findings << { severity: 'warn', code: 'no-wildcard-agent', message: "No '*' (wildcard) User-agent group — rules only bind to named bots." } unless rs.agents.include?('*')
|
|
142
|
+
findings << { severity: 'info', code: 'sitemap-declared', message: "Sitemap(s) declared: #{rs.sitemaps.join(', ')}" } unless rs.sitemaps.empty?
|
|
143
|
+
findings << { severity: 'info', code: 'no-sitemap', message: "No Sitemap: line — declaring one helps crawlers discover URLs." } if rs.sitemaps.empty?
|
|
144
|
+
findings
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.audit(url, timeout: 15)
|
|
149
|
+
robots_url, res = Fetcher.fetch(url, timeout: timeout)
|
|
150
|
+
body = res.body.to_s
|
|
151
|
+
[robots_url, Audit.new(robots_url, res, body).run]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if $PROGRAM_NAME == __FILE__
|
|
156
|
+
opts = { json: false }
|
|
157
|
+
OptionParser.new do |o|
|
|
158
|
+
o.banner = 'Usage: robots-audit [options] URL [URL ...]'
|
|
159
|
+
o.on('--json', 'Emit JSON instead of text') { opts[:json] = true }
|
|
160
|
+
o.on('-T SEC', Integer, 'HTTP timeout (default 15)') { |v| opts[:timeout] = v }
|
|
161
|
+
o.on('-v', '--version') { puts "robots-audit #{RobotsAudit::VERSION}"; exit 0 }
|
|
162
|
+
end.parse!
|
|
163
|
+
|
|
164
|
+
urls = ARGV
|
|
165
|
+
abort('Usage: robots-audit [--json] URL [URL ...]') if urls.empty?
|
|
166
|
+
|
|
167
|
+
worst = 0
|
|
168
|
+
results = urls.map do |u|
|
|
169
|
+
begin
|
|
170
|
+
robots_url, findings = RobotsAudit.audit(u, timeout: opts[:timeout] || 15)
|
|
171
|
+
max = findings.map { |f| f[:severity] == 'warn' ? 1 : 0 }.max.to_i
|
|
172
|
+
worst = [worst, max].max
|
|
173
|
+
{ url: u, robots_url: robots_url.to_s, ok: true, findings: findings.map { |f| { severity: f[:severity], code: f[:code], message: f[:message] } } }
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
worst = [worst, 1].max
|
|
176
|
+
{ url: u, ok: false, error: e.class.name, message: e.message }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if opts[:json]
|
|
181
|
+
puts JSON.pretty_generate(results)
|
|
182
|
+
else
|
|
183
|
+
results.each do |r|
|
|
184
|
+
if r[:ok]
|
|
185
|
+
puts "#{r[:url]} -> #{r[:robots_url]}"
|
|
186
|
+
r[:findings].each { |f| puts " [#{f[:severity].upcase}] #{f[:code]}: #{f[:message]}" }
|
|
187
|
+
puts " #{r[:findings].empty? ? 'No findings — looks clean.' : "#{r[:findings].size} finding(s)."}"
|
|
188
|
+
else
|
|
189
|
+
puts "#{r[:url]} -> ERROR #{r[:error]}: #{r[:message]}"
|
|
190
|
+
end
|
|
191
|
+
puts
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
exit(worst)
|
|
195
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: robots-audit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- DeveloApps
|
|
8
|
+
bindir: "."
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-10-17 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'robots-audit fetches a site''s robots.txt (following redirects), parses
|
|
13
|
+
it per the robots exclusion protocol, and reports actionable findings: parse violations,
|
|
14
|
+
orphan Disallow lines, missing wildcard User-agent coverage, missing sitemap declarations,
|
|
15
|
+
unexpected HTTP statuses, and oversize files. Zero runtime dependencies — stdlib
|
|
16
|
+
only (net/http, uri, optparse, json). Use it for SEO hygiene checks, crawl-budget
|
|
17
|
+
reviews, and CI gates.'
|
|
18
|
+
email:
|
|
19
|
+
- marky@develoapps.com
|
|
20
|
+
executables:
|
|
21
|
+
- robots_audit.rb
|
|
22
|
+
extensions: []
|
|
23
|
+
extra_rdoc_files: []
|
|
24
|
+
files:
|
|
25
|
+
- "./robots_audit.rb"
|
|
26
|
+
- CHANGELOG.md
|
|
27
|
+
- LICENSE.txt
|
|
28
|
+
- README.md
|
|
29
|
+
- robots_audit.rb
|
|
30
|
+
homepage: https://techemc.com/
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
homepage_uri: https://techemc.com/
|
|
35
|
+
source_code_uri: https://techemc.com/
|
|
36
|
+
changelog_uri: https://techemc.com/
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- "."
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: 2.7.0
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 3.6.2
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: 'Audit any site''s robots.txt: parse checks, wildcard/sitemap coverage, redirect
|
|
54
|
+
handling, JSON output.'
|
|
55
|
+
test_files: []
|