seo_geo 0.1.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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +244 -0
  3. data/CHANGELOG.md +40 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +428 -0
  6. data/app/controllers/seo_geo/index_now_controller.rb +13 -0
  7. data/app/controllers/seo_geo/llms_controller.rb +29 -0
  8. data/app/controllers/seo_geo/og_images_controller.rb +67 -0
  9. data/app/controllers/seo_geo/robots_controller.rb +14 -0
  10. data/app/controllers/seo_geo/sitemaps_controller.rb +16 -0
  11. data/app/jobs/seo_geo/index_now_job.rb +21 -0
  12. data/app/views/seo_geo/fallback/_body.html.erb +21 -0
  13. data/app/views/seo_geo/fallback/_faq.html.erb +12 -0
  14. data/config/routes.rb +17 -0
  15. data/lib/generators/seo_geo/install/install_generator.rb +138 -0
  16. data/lib/generators/seo_geo/install/templates/home.yml.tt +14 -0
  17. data/lib/generators/seo_geo/install/templates/initializer.rb.tt +52 -0
  18. data/lib/seo_geo/audit/checks.rb +107 -0
  19. data/lib/seo_geo/audit/html_document.rb +132 -0
  20. data/lib/seo_geo/audit/report.rb +85 -0
  21. data/lib/seo_geo/audit/runner.rb +327 -0
  22. data/lib/seo_geo/audit/static_scan.rb +60 -0
  23. data/lib/seo_geo/audit.rb +14 -0
  24. data/lib/seo_geo/bot_verifier.rb +278 -0
  25. data/lib/seo_geo/configuration.rb +832 -0
  26. data/lib/seo_geo/controller.rb +72 -0
  27. data/lib/seo_geo/crawler_fallback.rb +45 -0
  28. data/lib/seo_geo/crawlers.rb +79 -0
  29. data/lib/seo_geo/engine.rb +123 -0
  30. data/lib/seo_geo/index_now.rb +187 -0
  31. data/lib/seo_geo/json_ld.rb +259 -0
  32. data/lib/seo_geo/llms_txt.rb +183 -0
  33. data/lib/seo_geo/markdown/document.rb +163 -0
  34. data/lib/seo_geo/markdown/negotiation.rb +49 -0
  35. data/lib/seo_geo/markdown/path_suffix.rb +72 -0
  36. data/lib/seo_geo/markdownable.rb +141 -0
  37. data/lib/seo_geo/meta.rb +382 -0
  38. data/lib/seo_geo/model.rb +95 -0
  39. data/lib/seo_geo/og_image/card.html.erb +22 -0
  40. data/lib/seo_geo/og_image/ferrum_backend.rb +72 -0
  41. data/lib/seo_geo/og_image/renderer.rb +163 -0
  42. data/lib/seo_geo/page.rb +201 -0
  43. data/lib/seo_geo/path_match.rb +36 -0
  44. data/lib/seo_geo/publication.rb +68 -0
  45. data/lib/seo_geo/robots/evaluator.rb +85 -0
  46. data/lib/seo_geo/robots.rb +105 -0
  47. data/lib/seo_geo/robots_directives.rb +170 -0
  48. data/lib/seo_geo/robots_path.rb +125 -0
  49. data/lib/seo_geo/rspec.rb +197 -0
  50. data/lib/seo_geo/safe_regexp.rb +50 -0
  51. data/lib/seo_geo/sitemap/registry.rb +239 -0
  52. data/lib/seo_geo/sitemap/xml.rb +138 -0
  53. data/lib/seo_geo/testing/environment.rb +31 -0
  54. data/lib/seo_geo/testing.rb +111 -0
  55. data/lib/seo_geo/undeclared_host.rb +40 -0
  56. data/lib/seo_geo/version.rb +5 -0
  57. data/lib/seo_geo.rb +80 -0
  58. data/lib/tasks/seo_geo.rake +23 -0
  59. data/llms.txt +27 -0
  60. metadata +224 -0
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "rack"
5
+ require "rack/mock"
6
+
7
+ module SeoGeo
8
+ module Audit
9
+ # Requests the application in-process (Rack, no network, no server) for every known host and
10
+ # collects {Finding}s. Outside an indexable environment the run temporarily switches
11
+ # {Configuration#environment} to the first of +indexable_environments+, so a development
12
+ # checkout is audited as production would serve it.
13
+ #
14
+ # Per host: robots.txt (200), sitemap.xml (valid; 404 when the policy has none), every sitemap URL
15
+ # (up to +max_pages+) and "/", llms.txt and its links. Per HTML page: one +<title>+ (length, unique),
16
+ # description length, canonical origin, noindex vs sitemap, robots vs sitemap, JSON-LD parse and
17
+ # required properties, FAQPage text visible, +<html lang>+, Markdown alternate (+.md+ and
18
+ # +Accept: text/markdown+ with +Vary: Accept+), verification metas. Plus {StaticScan}.
19
+ #
20
+ # @example
21
+ # report = SeoGeo::Audit::Runner.new(app: Rails.application, root: Rails.root).run
22
+ # puts report
23
+ class Runner
24
+ MARKDOWN_TYPE = %r{\Atext/markdown}i
25
+
26
+ # @param app [#call] Rack application
27
+ # @param config [Configuration]
28
+ # @param sitemap [Sitemap::Registry]
29
+ # @param root [String, Pathname, nil] application root for {StaticScan}; nil skips it
30
+ # @param hosts [Array<String>, nil] hosts to audit; nil => {Configuration#known_hosts}
31
+ # @param max_pages [Integer] sitemap URLs fetched per host
32
+ def initialize(app:, config: SeoGeo.config, sitemap: SeoGeo.sitemap, root: nil, hosts: nil, max_pages: 200)
33
+ @app = app
34
+ @config = config
35
+ @sitemap = sitemap
36
+ @root = root
37
+ @hosts = hosts
38
+ @max_pages = max_pages
39
+ @report = Report.new
40
+ @titles = Hash.new { |hash, key| hash[key] = [] }
41
+ end
42
+
43
+ # @return [Report]
44
+ def run
45
+ StaticScan.new(@root).call(@report) if @root
46
+ unmatched_index_collections
47
+ with_indexable_environment do
48
+ (@hosts || @config.known_hosts).each { |host| audit_host(host.to_s.downcase) }
49
+ end
50
+ duplicate_titles
51
+ @report
52
+ end
53
+
54
+ private
55
+
56
+ def audit_host(host)
57
+ policy = @config.host_policy(host)
58
+ robots = fetch(host, "/robots.txt")
59
+ evaluator = nil
60
+ if robots.status == 200
61
+ evaluator = Robots::Evaluator.new(robots.body)
62
+ else
63
+ error(host, "/robots.txt", "robots.status", "robots.txt returned #{robots.status}")
64
+ end
65
+
66
+ urls = policy.sitemap ? sitemap_urls(host) : expect_no_sitemap(host)
67
+ pages = urls.first(@max_pages)
68
+ @report.stats["skipped"] += urls.size - pages.size if urls.size > pages.size
69
+ pages.each do |url|
70
+ check_robots_allows(host, url, evaluator) if evaluator
71
+ audit_page(host, url, from_sitemap: true)
72
+ end
73
+ home = "#{@config.origin_for(host)}/"
74
+ unless pages.any? { |url| same_page?(url, home) }
75
+ audit_page(host, home, from_sitemap: false, full: policy.sitemap)
76
+ end
77
+ audit_llms(host) if policy.sitemap && @config.llms_txt.enabled
78
+ end
79
+
80
+ def sitemap_urls(host)
81
+ response = fetch(host, "/sitemap.xml")
82
+ unless response.status == 200
83
+ error(host, "/sitemap.xml", "sitemap.status", "sitemap.xml returned #{response.status} but the host policy has a sitemap")
84
+ return []
85
+ end
86
+
87
+ origin = "#{@config.origin_for(host)}/"
88
+ problems, locs, kind = Checks.sitemap(response.body, origin: origin)
89
+ problems.each { |message| error(host, "/sitemap.xml", "sitemap.valid", message) }
90
+ return locs unless kind == :sitemapindex
91
+
92
+ locs.flat_map do |part|
93
+ part_response = fetch(host, request_path(part))
94
+ part_problems, part_locs, = Checks.sitemap(part_response.body, origin: origin)
95
+ part_problems.each { |message| error(host, part, "sitemap.valid", message) }
96
+ part_locs
97
+ end
98
+ end
99
+
100
+ def expect_no_sitemap(host)
101
+ response = fetch(host, "/sitemap.xml")
102
+ if response.status == 200
103
+ error(host, "/sitemap.xml", "sitemap.unexpected", "sitemap.xml is served but the host policy says no sitemap")
104
+ end
105
+ []
106
+ end
107
+
108
+ def check_robots_allows(host, url, evaluator)
109
+ path = request_path(url)
110
+ # Groups the configuration denies on purpose (crawlers.training = :deny, overrides) are not problems.
111
+ agents = (["*"] + (evaluator.agents - ["*"])).uniq.reject { |agent| @config.crawlers.policy_for(agent) == :deny }
112
+ blocked = agents.reject { |agent| evaluator.allowed?(agent, path) }
113
+ return if blocked.empty?
114
+
115
+ error(host, url, "sitemap.robots_blocked", "listed in sitemap.xml but robots.txt disallows it for #{blocked.join(", ")}")
116
+ end
117
+
118
+ # +full: false+ (hosts without a sitemap, e.g. aliases): status, title count and canonical only.
119
+ def audit_page(host, url, from_sitemap:, full: true)
120
+ response = fetch(host, request_path(url))
121
+ @report.stats["pages"] += 1
122
+ unless response.status == 200
123
+ severity = from_sitemap || response.status >= 400 ? :error : :warning
124
+ @report.add(severity, "page.status", "returned #{response.status}#{location(response)}", host: host, url: url)
125
+ return
126
+ end
127
+ return unless response.content_type.to_s.include?("html")
128
+
129
+ doc = HtmlDocument.new(response.body)
130
+ return check_canonical(host, url, doc) unless full
131
+
132
+ noindex = doc.noindex?(response.headers["x-robots-tag"])
133
+ error(host, url, "sitemap.noindex", "listed in sitemap.xml but the page is noindex") if noindex && from_sitemap
134
+ check_title_and_description(host, url, doc, noindex)
135
+ check_canonical(host, url, doc)
136
+ Checks.json_ld(doc).each { |message| error(host, url, "json_ld", message) }
137
+ Checks.faq_visible(doc).each { |message| error(host, url, "faq.visible", message) }
138
+ warning(host, url, "html.lang", "<html> has no lang attribute") unless doc.html_lang
139
+ check_verification(host, url, doc) if same_page?(url, "#{@config.canonical_origin}/")
140
+ check_markdown(host, url, doc) if markdown_expected?(url, doc)
141
+ end
142
+
143
+ def check_canonical(host, url, doc)
144
+ Checks.title_count(doc).each { |message| error(host, url, "title.count", message) }
145
+ expected = URI.parse(@config.canonical_url(url))
146
+ Checks.canonical_origin(doc.canonical, "#{expected.scheme}://#{expected.host}#{port_suffix(expected)}").each do |message|
147
+ error(host, url, "canonical", message)
148
+ end
149
+ end
150
+
151
+ def check_title_and_description(host, url, doc, noindex)
152
+ title = doc.title
153
+ if title
154
+ warning(host, url, "title.length", "title is #{title.length} characters (> #{Checks::TITLE_MAX})") if title.length > Checks::TITLE_MAX
155
+ @titles[[host, title]] << url unless noindex
156
+ end
157
+ description = doc.description.to_s.strip
158
+ if description.empty?
159
+ warning(host, url, "description.missing", "no meta description")
160
+ elsif !Checks::DESCRIPTION_RANGE.cover?(description.length)
161
+ warning(host, url, "description.length",
162
+ "description is #{description.length} characters (#{Checks::DESCRIPTION_RANGE.min}–#{Checks::DESCRIPTION_RANGE.max} expected)")
163
+ end
164
+ end
165
+
166
+ def check_verification(host, url, doc)
167
+ @config.verification.meta_tags.each do |name, value|
168
+ next if doc.meta_content(name) == value
169
+
170
+ error(host, url, "verification", "<meta name=\"#{name}\"> missing or different from the configured token")
171
+ end
172
+ end
173
+
174
+ # Pages registered with +markdown: false+ are not checked for Markdown (they do not serve it).
175
+ def markdown_expected?(url, doc)
176
+ @config.markdown.suffix_urls? && doc.markdown_alternate && !@sitemap.markdown_disabled?(request_path(url))
177
+ end
178
+
179
+ def check_markdown(host, url, doc)
180
+ markdown_url = doc.markdown_alternate
181
+ dot_md = fetch(host, request_path(markdown_url))
182
+ unless dot_md.status == 200 && dot_md.content_type.to_s.match?(MARKDOWN_TYPE)
183
+ error(host, markdown_url, "markdown.url", "advertised Markdown URL returned #{dot_md.status} #{dot_md.content_type}")
184
+ end
185
+
186
+ negotiated = fetch(host, request_path(url), "HTTP_ACCEPT" => "text/markdown")
187
+ unless negotiated.status == 200 && negotiated.content_type.to_s.match?(MARKDOWN_TYPE)
188
+ error(host, url, "markdown.accept", "page advertises Markdown but Accept: text/markdown returned " \
189
+ "#{negotiated.status} #{negotiated.content_type} (include SeoGeo::Markdownable " \
190
+ "or pass seo markdown: false)")
191
+ return
192
+ end
193
+ vary = negotiated.headers["vary"].to_s.split(",").map { |value| value.strip.downcase }
194
+ error(host, url, "markdown.vary", "Markdown response has no Vary: Accept") unless vary.include?("accept")
195
+ end
196
+
197
+ def audit_llms(host)
198
+ response = fetch(host, "/llms.txt")
199
+ unless response.status == 200
200
+ error(host, "/llms.txt", "llms.status", "llms.txt returned #{response.status}")
201
+ return
202
+ end
203
+
204
+ origins = [@config.origin_for(host), @config.canonical_origin].uniq
205
+ response.body.scan(/\]\((https?:\/\/[^)\s]+)\)/).flatten.uniq.each do |link|
206
+ next unless origins.any? { |origin| link.start_with?("#{origin}/") || link == origin }
207
+
208
+ target = URI.parse(link)
209
+ link_host = target.host.to_s.downcase
210
+ link_response = fetch(link_host, request_path(link))
211
+ @report.stats["llms_links"] += 1
212
+ unless link_response.status == 200
213
+ error(host, link, "llms.link", "llms.txt link returned #{link_response.status}#{location(link_response)}")
214
+ next
215
+ end
216
+ # .md links exist only for entries with markdown: true (or nil + markdown.enabled); they are served
217
+ # by SeoGeo::Markdown::PathSuffix + Markdownable. A 406 above means the action is not markdownable.
218
+ next unless target.path.to_s.end_with?(".md") && !link_response.content_type.to_s.match?(MARKDOWN_TYPE)
219
+
220
+ error(host, link, "llms.markdown", "llms.txt links the .md form but it returned #{link_response.content_type} " \
221
+ "(pass markdown: false to llms_txt.page/link for non-Markdownable actions)")
222
+ end
223
+ end
224
+
225
+ def duplicate_titles
226
+ @titles.each do |(host, title), urls|
227
+ next if urls.uniq.size < 2
228
+
229
+ warning(host, urls.first, "title.duplicate", "title #{title.inspect} is shared by #{urls.uniq.size} URLs: #{urls.uniq.first(5).join(", ")}")
230
+ end
231
+ end
232
+
233
+ # @return [Rack::MockResponse]
234
+ def fetch(host, path, headers = {})
235
+ origin = URI.parse(@config.origin_for(host))
236
+ scheme = origin.scheme || "https"
237
+ # Rails HostAuthorization (config.hosts) reads the Host header, not SERVER_NAME: send both, with the port
238
+ # only when it is not the scheme's default.
239
+ authority = origin.port && origin.port != origin.default_port ? "#{host}:#{origin.port}" : host
240
+ env = Rack::MockRequest.env_for("#{scheme}://#{authority}#{path}",
241
+ { "REMOTE_ADDR" => "127.0.0.1", "HTTP_ACCEPT" => "text/html" }.merge(headers))
242
+ env["HTTP_HOST"] = authority
243
+ env["HTTPS"] = "on" if scheme == "https"
244
+ status, response_headers, body = @app.call(env)
245
+ response = Rack::MockResponse.new(status, response_headers, body)
246
+ if status == 403 && host_blocked?(host, response)
247
+ error(host, path, "host_authorization", "Rails HostAuthorization blocked Host: #{authority}; run it through " \
248
+ "`bin/rails seo_geo:audit` (or set SEO_GEO_AUDIT=1 before boot, e.g. for " \
249
+ "bin/rails runner) so seo_geo allows its hosts outside production, or " \
250
+ "add #{host.inspect} to config.hosts")
251
+ end
252
+ response
253
+ rescue StandardError => e
254
+ error(host, path, "exception", "#{e.class}: #{e.message.lines.first.to_s.strip}")
255
+ Rack::MockResponse.new(500, {}, [])
256
+ end
257
+
258
+ # Rails HostAuthorization answers 403 with an empty body unless detailed exceptions are on, so ask the
259
+ # app's own config.hosts as well as reading the "Blocked hosts" page.
260
+ def host_blocked?(host, response)
261
+ return true if response.body.include?("Blocked host")
262
+ return false unless @app.respond_to?(:config) && @app.config.respond_to?(:hosts) && !@app.config.hosts.empty?
263
+ return false unless defined?(ActionDispatch::HostAuthorization::Permissions)
264
+
265
+ !ActionDispatch::HostAuthorization::Permissions.new(@app.config.hosts).allows?(host)
266
+ end
267
+
268
+ def with_indexable_environment
269
+ return yield if @config.indexable_environment?
270
+
271
+ saved = @config.environment
272
+ begin
273
+ @config.environment = @config.indexable_environments.first.to_s
274
+ @report.stats["audited as #{@config.environment}"] = 1
275
+ yield
276
+ ensure
277
+ @config.environment = saved
278
+ end
279
+ end
280
+
281
+ def request_path(url)
282
+ uri = URI.parse(url)
283
+ path = uri.path.to_s.empty? ? "/" : uri.path
284
+ uri.query ? "#{path}?#{uri.query}" : path
285
+ rescue URI::InvalidURIError
286
+ url
287
+ end
288
+
289
+ def same_page?(url_a, url_b)
290
+ URI.parse(url_a).then { |a| [a.host, a.path.to_s.empty? ? "/" : a.path] } ==
291
+ URI.parse(url_b).then { |b| [b.host, b.path.to_s.empty? ? "/" : b.path] }
292
+ rescue URI::InvalidURIError
293
+ false
294
+ end
295
+
296
+ def port_suffix(uri)
297
+ [80, 443].include?(uri.port) ? "" : ":#{uri.port}"
298
+ end
299
+
300
+ def location(response)
301
+ target = response.headers["location"]
302
+ target ? " (→ #{target})" : ""
303
+ end
304
+
305
+ # Under default_noindex a collection added with index: true lists its records, but without match: the head
306
+ # cannot tell a record path belongs to it, so the page stays noindex unless the controller says
307
+ # +seo index: true+. The page audit then reports sitemap.noindex; this names the cause.
308
+ def unmatched_index_collections
309
+ return unless @config.default_noindex
310
+
311
+ @sitemap.unmatched_index_collections.each do |entry|
312
+ @report.add(:warning, "sitemap.collection_index",
313
+ "collection #{entry.source.class} is added with index: true but no match:, so its record pages " \
314
+ "are noindex unless their controller calls seo index: true (add match: ->(path) { ... })")
315
+ end
316
+ end
317
+
318
+ def error(host, url, check, message)
319
+ @report.add(:error, check, message, host: host, url: url)
320
+ end
321
+
322
+ def warning(host, url, check, message)
323
+ @report.add(:warning, check, message, host: host, url: url)
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ module Audit
5
+ # File-system checks that need no request: static files shadowing the engine routes, and app code
6
+ # that branches on the User-Agent header (a User-Agent is a claim, not an identity — anyone can send
7
+ # "GPTBot"; see {SeoGeo::BotVerifier}).
8
+ class StaticScan
9
+ SHADOWED_FILES = %w[robots.txt sitemap.xml llms.txt llms-full.txt].freeze
10
+ USER_AGENT_READ = /request\.user_agent|request\.headers\[\s*["']User-Agent["']\s*\]|HTTP_USER_AGENT/
11
+ BRANCHING = /\b(?:if|unless|elsif|when|case|while|until)\b|&&|\|\||=~|\?\s|\.(?:match\??|include\?|start_with\?|end_with\?|in\?)|\?\z/
12
+ SCANNED = %w[app/**/*.rb app/**/*.erb lib/**/*.rb lib/**/*.rake].freeze
13
+
14
+ # @param root [String, Pathname] application root
15
+ def initialize(root)
16
+ @root = root.to_s
17
+ end
18
+
19
+ # @param report [Report]
20
+ # @return [Report]
21
+ def call(report)
22
+ shadowed_files(report)
23
+ user_agent_branches(report)
24
+ report
25
+ end
26
+
27
+ private
28
+
29
+ def shadowed_files(report)
30
+ SHADOWED_FILES.each do |name|
31
+ path = File.join(@root, "public", name)
32
+ next unless File.file?(path)
33
+
34
+ report.add(:error, "static.shadowing", "public/#{name} is served before the seo_geo route; delete it",
35
+ url: "public/#{name}")
36
+ end
37
+ end
38
+
39
+ def user_agent_branches(report)
40
+ SCANNED.flat_map { |pattern| Dir.glob(File.join(@root, pattern)) }.uniq.sort.each do |file|
41
+ File.foreach(file).with_index(1) do |line, number|
42
+ code = line.chomp.sub(/#(?!\{).*\z/, "")
43
+ next unless code.match?(USER_AGENT_READ) && code.match?(BRANCHING)
44
+
45
+ report.add(:warning, "code.user_agent_branch",
46
+ "branches on the User-Agent header; never use it for access, content or rate-limit " \
47
+ "decisions (it is spoofable) — use SeoGeo::BotVerifier when a bot must be trusted",
48
+ url: "#{relative(file)}:#{number}")
49
+ end
50
+ rescue ArgumentError, Errno::ENOENT
51
+ next
52
+ end
53
+ end
54
+
55
+ def relative(file)
56
+ file.delete_prefix("#{@root}/")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # +rake seo_geo:audit+: requests the app in-process for every known host and reports
5
+ # problems that search engines and AI crawlers would hit. See {Audit::Runner}.
6
+ module Audit
7
+ end
8
+ end
9
+
10
+ require_relative "audit/report"
11
+ require_relative "audit/html_document"
12
+ require_relative "audit/checks"
13
+ require_relative "audit/static_scan"
14
+ require_relative "audit/runner"
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "json"
5
+ require "net/http"
6
+ require "resolv"
7
+ require "uri"
8
+
9
+ module SeoGeo
10
+ # Optional: checks whether a request that *claims* to be a crawler (by User-Agent) really comes from
11
+ # that operator. A User-Agent is a claim, not an identity — anyone can send "Googlebot".
12
+ #
13
+ # seo_geo itself never uses this (or the User-Agent) for access decisions: robots.txt is the only
14
+ # place the gem reads crawler names, Markdown is chosen by the Accept header, and every client gets
15
+ # the same public content. Use the verifier only when your app must grant a bot something extra
16
+ # (skip a rate limit, allow a paywalled preview), and then require +verified == true+.
17
+ #
18
+ # Methods (sources checked 2026-10-14):
19
+ # - Reverse DNS + forward confirmation (PTR host ends in an operator domain, and that host resolves
20
+ # back to the same IP):
21
+ # - Googlebot: googlebot.com, google.com —
22
+ # https://developers.google.com/crawling/docs/crawlers-fetchers/verify-google-requests
23
+ # (googleusercontent.com is deliberately NOT accepted: it also names customer Google Cloud hosts and is
24
+ # documented only for user-triggered fetchers, not Googlebot)
25
+ # - Bingbot: search.msn.com — https://www.bing.com/toolbox/verify-bingbot
26
+ # - Applebot: applebot.apple.com — https://support.apple.com/en-us/119829
27
+ # - Yeti (Naver): naver.com — NOT documented by Naver (searchadvisor.naver.com only covers robots.txt);
28
+ # the suffix is common practice (PTR names like crawl.<ip>.web.naver.com). Treat as best effort.
29
+ # - Published IP ranges (+{"prefixes":[{"ipv4Prefix"|"ipv6Prefix": CIDR}]}+), cached for +ttl+ seconds;
30
+ # when a refresh fails the old list is used for at most another +ttl+, then the result is +nil+:
31
+ # - OpenAI GPTBot / OAI-SearchBot / ChatGPT-User — https://developers.openai.com/api/docs/bots
32
+ # (openai.com/gptbot.json, searchbot.json, chatgpt-user.json)
33
+ # - Perplexity PerplexityBot / Perplexity-User — https://docs.perplexity.ai/guides/bots
34
+ # - Anthropic ClaudeBot / Claude-User / Claude-SearchBot — https://support.claude.com/en/articles/8896518
35
+ # (one list for all three: claude.com/crawling/bots.json)
36
+ # - Everything else (CCBot, Meta, Bytespider, ...) => +verified: nil+ (no published method).
37
+ #
38
+ # Web Bot Auth (HTTP Message Signatures for bots, IETF draft-meunier-webbotauth-httpsig-protocol):
39
+ # requests carrying +Signature-Agent+, or +Signature+ with +Signature-Input+, are reported as
40
+ # +signed: true+. Verifying the signature is TODO — +signed+ says nothing about who signed.
41
+ #
42
+ # @example config/application.rb (opt-in; place after ActionDispatch::RemoteIp so proxies are honoured)
43
+ # config.middleware.use SeoGeo::BotVerifier::Middleware
44
+ # # later, in a controller:
45
+ # bot = request.env["seo_geo.bot"] # nil for ordinary visitors
46
+ # skip_rate_limit if bot && bot[:verified] == true
47
+ class BotVerifier
48
+ ENV_KEY = "seo_geo.bot"
49
+ DEFAULT_TTL = 24 * 60 * 60
50
+ # Seconds a failed range download is not retried (avoids stacking timeouts behind the refresh lock).
51
+ FAILURE_BACKOFF = 60
52
+
53
+ OPENAI = "https://openai.com"
54
+ PERPLEXITY = "https://www.perplexity.com"
55
+ ANTHROPIC_RANGES = "https://claude.com/crawling/bots.json"
56
+
57
+ # Verification method per User-Agent token: +dns:+ PTR suffixes, or +ranges:+ IP-range JSON URL.
58
+ METHODS = {
59
+ "Googlebot" => { dns: %w[googlebot.com google.com] },
60
+ "Bingbot" => { dns: %w[search.msn.com] },
61
+ "Applebot" => { dns: %w[applebot.apple.com] },
62
+ "Yeti" => { dns: %w[naver.com] },
63
+ "GPTBot" => { ranges: "#{OPENAI}/gptbot.json" },
64
+ "OAI-SearchBot" => { ranges: "#{OPENAI}/searchbot.json" },
65
+ "ChatGPT-User" => { ranges: "#{OPENAI}/chatgpt-user.json" },
66
+ "PerplexityBot" => { ranges: "#{PERPLEXITY}/perplexitybot.json" },
67
+ "Perplexity-User" => { ranges: "#{PERPLEXITY}/perplexity-user.json" },
68
+ "ClaudeBot" => { ranges: ANTHROPIC_RANGES },
69
+ "Claude-User" => { ranges: ANTHROPIC_RANGES },
70
+ "Claude-SearchBot" => { ranges: ANTHROPIC_RANGES }
71
+ }.freeze
72
+
73
+ # Crawlers that fetch pages but are not in {Crawlers::ALL} (whose Apple entry is the robots-only
74
+ # Applebot-Extended token).
75
+ EXTRA_CRAWLERS = { "Applebot" => :search }.freeze
76
+
77
+ # Default DNS resolver: Resolv with a 2-second timeout. Anything with +getnames(ip)+ and
78
+ # +getaddresses(name)+ works.
79
+ def self.default_resolver
80
+ Resolv::DNS.new.tap { |dns| dns.timeouts = 2 }
81
+ end
82
+
83
+ # Default range fetcher: +(url) -> body+ over HTTPS with short timeouts; raises on non-200.
84
+ DEFAULT_FETCHER = lambda do |url|
85
+ uri = URI.parse(url)
86
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 3, read_timeout: 5) do |http|
87
+ http.get(uri.request_uri, "Accept" => "application/json", "User-Agent" => "seo_geo/#{SeoGeo::VERSION}")
88
+ end
89
+ raise IOError, "GET #{url} returned #{response.code}" unless response.is_a?(Net::HTTPSuccess)
90
+
91
+ response.body
92
+ end
93
+
94
+ # @return [BotVerifier] process-wide instance used by {Middleware} when none is given
95
+ def self.default
96
+ @default ||= new
97
+ end
98
+
99
+ # @param resolver [#getnames, #getaddresses] DNS (tests inject a stub)
100
+ # @param fetcher [#call] +(url) -> String+ JSON body (tests inject a stub)
101
+ # @param ttl [Integer] seconds an IP-range list and a DNS answer stay cached
102
+ # @param clock [#call] monotonic seconds (tests)
103
+ def initialize(resolver: self.class.default_resolver, fetcher: DEFAULT_FETCHER, ttl: DEFAULT_TTL,
104
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
105
+ @resolver = resolver
106
+ @fetcher = fetcher
107
+ @ttl = ttl
108
+ @clock = clock
109
+ @ranges = {}
110
+ @dns = {}
111
+ @mutex = Mutex.new
112
+ @refresh = Mutex.new
113
+ end
114
+
115
+ # @param user_agent [String, nil] User-Agent header
116
+ # @param ip [String, nil] client IP
117
+ # @param headers [Hash{String => String}] Rack env or header hash (for Web Bot Auth detection)
118
+ # @return [Hash, nil] +{name:, purpose:, claimed:, verified:, signed:}+; nil when the request neither
119
+ # claims a known crawler nor carries Web Bot Auth headers. +verified+ is true/false, or nil when the
120
+ # operator publishes no verification method or the check could not run (DNS/range fetch failure).
121
+ def verify(user_agent:, ip:, headers: {})
122
+ name, purpose = identify(user_agent)
123
+ signed = signed?(headers)
124
+ return nil if name.nil? && !signed
125
+
126
+ { name: name, purpose: purpose, claimed: !name.nil?, verified: name && verified?(name, ip), signed: signed }
127
+ end
128
+
129
+ # @param user_agent [String, nil]
130
+ # @return [Array(String, Symbol), nil] token and purpose of the crawler the User-Agent claims
131
+ def identify(user_agent)
132
+ return nil if user_agent.nil? || user_agent.strip.empty?
133
+
134
+ crawler = Crawlers.find(user_agent)
135
+ crawler = nil if crawler&.token_only
136
+ return [crawler.user_agent, crawler.purpose] if crawler
137
+
138
+ token = EXTRA_CRAWLERS.keys.find { |name| user_agent.downcase.include?(name.downcase) }
139
+ token && [token, EXTRA_CRAWLERS[token]]
140
+ end
141
+
142
+ # @param name [String] crawler token
143
+ # @param ip [String, nil]
144
+ # @return [Boolean, nil]
145
+ def verified?(name, ip)
146
+ method = METHODS[name]
147
+ address = parse_ip(ip)
148
+ return nil if method.nil?
149
+ return false if address.nil?
150
+
151
+ method[:dns] ? dns_verified?(address, method[:dns]) : in_ranges?(address, method[:ranges])
152
+ end
153
+
154
+ # @param headers [Hash]
155
+ # @return [Boolean] true when Web Bot Auth headers are present (signature NOT verified)
156
+ def signed?(headers)
157
+ lookup = ->(name) { headers[name] || headers["HTTP_#{name.upcase.tr("-", "_")}"] || headers[name.downcase] }
158
+ present = ->(value) { !value.nil? && !value.to_s.strip.empty? }
159
+ present.call(lookup.call("Signature-Agent")) ||
160
+ (present.call(lookup.call("Signature")) && present.call(lookup.call("Signature-Input")))
161
+ end
162
+
163
+ # Drops cached ranges and DNS answers.
164
+ # @return [void]
165
+ def clear_cache
166
+ @mutex.synchronize do
167
+ @ranges.clear
168
+ @dns.clear
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ # A single host address (no CIDR notation); IPv4-mapped IPv6 is folded to IPv4.
175
+ def parse_ip(ip)
176
+ text = ip.to_s.strip
177
+ return nil if text.empty? || text.include?("/")
178
+
179
+ address = IPAddr.new(text)
180
+ address.ipv4_mapped? ? address.native : address
181
+ rescue IPAddr::Error
182
+ nil
183
+ end
184
+
185
+ def dns_verified?(address, suffixes)
186
+ key = [address.to_s, suffixes]
187
+ cached(@dns, key) do
188
+ names = @resolver.getnames(address.to_s).map { |name| name.to_s.downcase.chomp(".") }
189
+ match = names.find { |name| suffixes.any? { |suffix| name == suffix || name.end_with?(".#{suffix}") } }
190
+ match ? @resolver.getaddresses(match).any? { |forward| same_ip?(forward, address) } : false
191
+ end
192
+ rescue Resolv::ResolvError, Resolv::ResolvTimeout, SocketError, SystemCallError
193
+ nil
194
+ end
195
+
196
+ def same_ip?(candidate, address)
197
+ IPAddr.new(candidate.to_s) == address
198
+ rescue IPAddr::Error
199
+ false
200
+ end
201
+
202
+ def in_ranges?(address, url)
203
+ prefixes = ranges(url)
204
+ prefixes && prefixes.any? { |range| range.family == address.family && range.include?(address) }
205
+ end
206
+
207
+ # Cached prefixes for +url+. Refreshes are serialized (a slow, older response can never overwrite a
208
+ # newer list). A failed refresh keeps the previous list for at most another +ttl+; nil means unknown.
209
+ def ranges(url)
210
+ entry = @mutex.synchronize { @ranges[url] }
211
+ return entry[:value] if entry && entry[:expires] > now
212
+
213
+ @refresh.synchronize do
214
+ entry = @mutex.synchronize { @ranges[url] }
215
+ return entry[:value] if entry && entry[:expires] > now
216
+
217
+ begin
218
+ value = parse_ranges(@fetcher.call(url))
219
+ @mutex.synchronize { @ranges[url] = { value: value, expires: now + @ttl, stale_until: now + (2 * @ttl) } }
220
+ value
221
+ rescue StandardError
222
+ stale = entry && entry[:stale_until] > now ? entry[:value] : nil
223
+ retry_at = now + [FAILURE_BACKOFF, @ttl].min
224
+ retry_at = [retry_at, entry[:stale_until]].min if stale
225
+ @mutex.synchronize do
226
+ @ranges[url] = { value: stale, expires: retry_at, stale_until: entry ? entry[:stale_until] : now }
227
+ end
228
+ stale
229
+ end
230
+ end
231
+ end
232
+
233
+ def parse_ranges(body)
234
+ JSON.parse(body.to_s).fetch("prefixes").filter_map do |prefix|
235
+ cidr = prefix["ipv4Prefix"] || prefix["ipv6Prefix"]
236
+ cidr && IPAddr.new(cidr)
237
+ end
238
+ end
239
+
240
+ def cached(store, key)
241
+ entry = @mutex.synchronize { store[key] }
242
+ return entry[:value] if entry && entry[:expires] > now
243
+
244
+ value = yield
245
+ @mutex.synchronize do
246
+ store.shift if store.size >= 10_000
247
+ store[key] = { value: value, expires: now + @ttl }
248
+ end
249
+ value
250
+ end
251
+
252
+ def now
253
+ @clock.call
254
+ end
255
+
256
+ # Rack middleware: sets +env["seo_geo.bot"]+ (see {BotVerifier#verify}) and nothing else — it never
257
+ # blocks, redirects or changes the response.
258
+ class Middleware
259
+ # @param app [#call]
260
+ # @param verifier [BotVerifier]
261
+ def initialize(app, verifier: BotVerifier.default)
262
+ @app = app
263
+ @verifier = verifier
264
+ end
265
+
266
+ def call(env)
267
+ env[ENV_KEY] = @verifier.verify(user_agent: env["HTTP_USER_AGENT"], ip: client_ip(env), headers: env)
268
+ @app.call(env)
269
+ end
270
+
271
+ private
272
+
273
+ def client_ip(env)
274
+ env["action_dispatch.remote_ip"]&.to_s || env["REMOTE_ADDR"]
275
+ end
276
+ end
277
+ end
278
+ end