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.
- checksums.yaml +7 -0
- data/AGENTS.md +244 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +428 -0
- data/app/controllers/seo_geo/index_now_controller.rb +13 -0
- data/app/controllers/seo_geo/llms_controller.rb +29 -0
- data/app/controllers/seo_geo/og_images_controller.rb +67 -0
- data/app/controllers/seo_geo/robots_controller.rb +14 -0
- data/app/controllers/seo_geo/sitemaps_controller.rb +16 -0
- data/app/jobs/seo_geo/index_now_job.rb +21 -0
- data/app/views/seo_geo/fallback/_body.html.erb +21 -0
- data/app/views/seo_geo/fallback/_faq.html.erb +12 -0
- data/config/routes.rb +17 -0
- data/lib/generators/seo_geo/install/install_generator.rb +138 -0
- data/lib/generators/seo_geo/install/templates/home.yml.tt +14 -0
- data/lib/generators/seo_geo/install/templates/initializer.rb.tt +52 -0
- data/lib/seo_geo/audit/checks.rb +107 -0
- data/lib/seo_geo/audit/html_document.rb +132 -0
- data/lib/seo_geo/audit/report.rb +85 -0
- data/lib/seo_geo/audit/runner.rb +327 -0
- data/lib/seo_geo/audit/static_scan.rb +60 -0
- data/lib/seo_geo/audit.rb +14 -0
- data/lib/seo_geo/bot_verifier.rb +278 -0
- data/lib/seo_geo/configuration.rb +832 -0
- data/lib/seo_geo/controller.rb +72 -0
- data/lib/seo_geo/crawler_fallback.rb +45 -0
- data/lib/seo_geo/crawlers.rb +79 -0
- data/lib/seo_geo/engine.rb +123 -0
- data/lib/seo_geo/index_now.rb +187 -0
- data/lib/seo_geo/json_ld.rb +259 -0
- data/lib/seo_geo/llms_txt.rb +183 -0
- data/lib/seo_geo/markdown/document.rb +163 -0
- data/lib/seo_geo/markdown/negotiation.rb +49 -0
- data/lib/seo_geo/markdown/path_suffix.rb +72 -0
- data/lib/seo_geo/markdownable.rb +141 -0
- data/lib/seo_geo/meta.rb +382 -0
- data/lib/seo_geo/model.rb +95 -0
- data/lib/seo_geo/og_image/card.html.erb +22 -0
- data/lib/seo_geo/og_image/ferrum_backend.rb +72 -0
- data/lib/seo_geo/og_image/renderer.rb +163 -0
- data/lib/seo_geo/page.rb +201 -0
- data/lib/seo_geo/path_match.rb +36 -0
- data/lib/seo_geo/publication.rb +68 -0
- data/lib/seo_geo/robots/evaluator.rb +85 -0
- data/lib/seo_geo/robots.rb +105 -0
- data/lib/seo_geo/robots_directives.rb +170 -0
- data/lib/seo_geo/robots_path.rb +125 -0
- data/lib/seo_geo/rspec.rb +197 -0
- data/lib/seo_geo/safe_regexp.rb +50 -0
- data/lib/seo_geo/sitemap/registry.rb +239 -0
- data/lib/seo_geo/sitemap/xml.rb +138 -0
- data/lib/seo_geo/testing/environment.rb +31 -0
- data/lib/seo_geo/testing.rb +111 -0
- data/lib/seo_geo/undeclared_host.rb +40 -0
- data/lib/seo_geo/version.rb +5 -0
- data/lib/seo_geo.rb +80 -0
- data/lib/tasks/seo_geo.rake +23 -0
- data/llms.txt +27 -0
- 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
|