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.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +64 -0
  5. data/robots_audit.rb +195 -0
  6. 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: []