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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# Builds robots.txt for one request host from {Configuration#host_policy} and {Configuration#crawlers}.
|
|
5
|
+
#
|
|
6
|
+
# RFC 9309 §2.2.1: a crawler obeys only the most specific group that names it and ignores +*+.
|
|
7
|
+
# So every named group carries the host's full rule list: allowed crawlers share the +*+ rule block
|
|
8
|
+
# (several +User-agent:+ lines, one block), denied crawlers get +Disallow: /+ plus {Configuration#allow_exceptions}
|
|
9
|
+
# and every host +disallow+, verbatim. Named groups do not inherit +*+, so a disallow more specific than an
|
|
10
|
+
# allow exception (+/.well-known/private+, or any encoding of it such as +/.%77ell-known/private+) must be
|
|
11
|
+
# repeated there; repeating all of them needs no prefix comparison that encodings could defeat.
|
|
12
|
+
# An +Allow: /+-only group is emitted only for tokens the app explicitly overrides to +:full_access+
|
|
13
|
+
# ({CrawlerOptions#override}); it re-opens host disallows to that bot by design. Closed hosts never get one.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# SeoGeo::Robots.new(SeoGeo.config, "www.example.com").to_s
|
|
17
|
+
class Robots
|
|
18
|
+
# @param config [Configuration]
|
|
19
|
+
# @param host [String] request host
|
|
20
|
+
def initialize(config, host)
|
|
21
|
+
@config = config
|
|
22
|
+
@host = host
|
|
23
|
+
@policy = config.host_policy(host)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] robots.txt body (ends with a newline)
|
|
27
|
+
def to_s
|
|
28
|
+
blocks = @policy.disallow_all? ? [closed_block] : open_blocks
|
|
29
|
+
trailer = [*sitemap_lines, *daum_lines]
|
|
30
|
+
text = blocks.map { |lines| lines.join("\n") }.join("\n\n")
|
|
31
|
+
text = "#{text}\n\n#{trailer.join("\n")}" unless trailer.empty?
|
|
32
|
+
"#{text}\n"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Array<String>] tokens sharing the +*+ rule block
|
|
36
|
+
def allowed_user_agents
|
|
37
|
+
managed_user_agents.select { |token| @config.crawlers.policy_for(token) == :allow }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Array<String>] tokens overridden to +:full_access+ (own group, +Allow: /+, host disallows ignored)
|
|
41
|
+
def full_access_user_agents
|
|
42
|
+
managed_user_agents.select { |token| @config.crawlers.policy_for(token) == :full_access }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Array<String>] tokens given +Disallow: /+
|
|
46
|
+
def denied_user_agents
|
|
47
|
+
managed_user_agents.select { |token| @config.crawlers.policy_for(token) == :deny }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Undeclared host, +indexable: false+ host, or a non-indexable environment.
|
|
53
|
+
def closed_block
|
|
54
|
+
["User-agent: *", *@policy.allow.map { |prefix| "Allow: #{prefix}" }, "Disallow: /"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def open_blocks
|
|
58
|
+
blocks = [shared_block]
|
|
59
|
+
denied = denied_user_agents
|
|
60
|
+
unless denied.empty?
|
|
61
|
+
blocks << [*denied.map { |token| "User-agent: #{token}" },
|
|
62
|
+
*@config.allow_exceptions.map { |prefix| "Allow: #{prefix}" }, "Disallow: /",
|
|
63
|
+
*@policy.disallow.uniq.map { |prefix| "Disallow: #{prefix}" }]
|
|
64
|
+
end
|
|
65
|
+
full = full_access_user_agents
|
|
66
|
+
unless full.empty?
|
|
67
|
+
blocks << [*full.map { |token| "User-agent: #{token}" }, "Allow: /",
|
|
68
|
+
*@config.allow_exceptions.map { |prefix| "Allow: #{prefix}" }]
|
|
69
|
+
end
|
|
70
|
+
blocks
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shared_block
|
|
74
|
+
lines = ["User-agent: *", *allowed_user_agents.map { |token| "User-agent: #{token}" }]
|
|
75
|
+
if @policy.disallow.empty?
|
|
76
|
+
lines << "Allow: /"
|
|
77
|
+
else
|
|
78
|
+
lines.concat(@policy.allow.map { |prefix| "Allow: #{prefix}" })
|
|
79
|
+
lines.concat(@policy.disallow.map { |prefix| "Disallow: #{prefix}" })
|
|
80
|
+
end
|
|
81
|
+
lines << "Content-Signal: #{@config.content_signal_value}"
|
|
82
|
+
usage = @config.content_usage_value
|
|
83
|
+
lines << "Content-Usage: #{usage}" if usage
|
|
84
|
+
lines
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def managed_user_agents
|
|
88
|
+
tokens = Crawlers::PURPOSES.flat_map { |purpose| @config.crawlers.user_agents(purpose) }
|
|
89
|
+
(tokens + @config.crawlers.overrides.keys).uniq { |token| token.downcase }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def sitemap_lines
|
|
93
|
+
return [] unless @policy.sitemap
|
|
94
|
+
|
|
95
|
+
["Sitemap: #{@config.origin_for(@host)}/sitemap.xml"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def daum_lines
|
|
99
|
+
pin = @config.verification.daum_pin
|
|
100
|
+
return [] if pin.nil? || pin.to_s.empty? || @policy.disallow_all?
|
|
101
|
+
|
|
102
|
+
["#DaumWebMasterTool:#{pin}"]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
module SeoGeo
|
|
7
|
+
# Robots directives of one response, from every +<meta name="robots">+, every crawler-specific meta
|
|
8
|
+
# (+<meta name="googlebot">+) and every +X-Robots-Tag+ header, kept apart by scope:
|
|
9
|
+
# +global+ applies to every crawler, +scoped["googlebot"]+ only to that one. A crawler obeys the union of
|
|
10
|
+
# +global+ and its own scope, so a scoped directive never stands in for a global one.
|
|
11
|
+
#
|
|
12
|
+
# Shared by the Markdown response (copies the HTML's directives into +X-Robots-Tag+) and the audit
|
|
13
|
+
# (is a sitemap page noindex?).
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# directives = SeoGeo::RobotsDirectives.from_html(html).merge(SeoGeo::RobotsDirectives.parse_header(value))
|
|
17
|
+
# directives.noindex? # global noindex
|
|
18
|
+
# directives.noindex?("googlebot") # global or googlebot-scoped
|
|
19
|
+
# directives.to_header # "noindex, googlebot: nofollow"
|
|
20
|
+
class RobotsDirectives
|
|
21
|
+
# Directives that carry a value after a colon; any other +name: rest+ token in a header names a crawler.
|
|
22
|
+
VALUE_DIRECTIVES = %w[max-snippet max-image-preview max-video-preview unavailable_after].freeze
|
|
23
|
+
# Meta names that address one crawler (besides the crawler table's tokens).
|
|
24
|
+
CRAWLER_META_NAMES = %w[googlebot googlebot-news googlebot-image bingbot msnbot slurp yandex yeti naverbot
|
|
25
|
+
daumoa baiduspider duckduckbot applebot].freeze
|
|
26
|
+
PERMISSIVE = %w[index follow all].freeze
|
|
27
|
+
|
|
28
|
+
# @return [Set<String>]
|
|
29
|
+
attr_reader :global
|
|
30
|
+
# @return [Hash{String => Set<String>}] crawler token (lower case) => directives
|
|
31
|
+
attr_reader :scoped
|
|
32
|
+
|
|
33
|
+
# @param html [String, Nokogiri::HTML::Document]
|
|
34
|
+
# @return [RobotsDirectives]
|
|
35
|
+
def self.from_html(html)
|
|
36
|
+
doc = html.is_a?(Nokogiri::XML::Node) ? html : Nokogiri::HTML(html.to_s)
|
|
37
|
+
new.tap do |directives|
|
|
38
|
+
doc.css("meta[name]").each do |node|
|
|
39
|
+
name = node["name"].to_s.strip.downcase
|
|
40
|
+
if name == "robots"
|
|
41
|
+
directives.add(nil, node["content"])
|
|
42
|
+
elsif crawler_name?(name)
|
|
43
|
+
directives.add(name, node["content"])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param value [String, Array<String>, nil] one or several +X-Robots-Tag+ values (Rack joins repeated
|
|
50
|
+
# headers with "\n"). Within one value, +bot: ...+ scopes the following directives to +bot+.
|
|
51
|
+
# @return [RobotsDirectives]
|
|
52
|
+
def self.parse_header(value)
|
|
53
|
+
new.tap do |directives|
|
|
54
|
+
Array(value).flat_map { |item| item.to_s.split("\n") }.each do |line|
|
|
55
|
+
scope = nil
|
|
56
|
+
tokens(line).each do |token|
|
|
57
|
+
|
|
58
|
+
name, rest = token.split(":", 2).map(&:strip)
|
|
59
|
+
if rest && !VALUE_DIRECTIVES.include?(name.downcase)
|
|
60
|
+
scope = name.downcase
|
|
61
|
+
token = rest
|
|
62
|
+
end
|
|
63
|
+
directives.add(scope, token)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A date-valued +unavailable_after+ may contain a comma (RFC 850/1123: +Wed, 03 Dec 2025 13:09:53 GMT+). The
|
|
70
|
+
# piece after +unavailable_after: <weekday>+ belongs to that directive, not to a new one.
|
|
71
|
+
DATE_HEAD = /(?:\A|:)\s*unavailable_after\s*:\s*[A-Za-z]+\s*\z/i
|
|
72
|
+
|
|
73
|
+
# Splits a comma-separated directive list, keeping date commas inside +unavailable_after+.
|
|
74
|
+
# @param content [String, nil]
|
|
75
|
+
# @return [Array<String>] stripped, non-empty tokens
|
|
76
|
+
def self.tokens(content)
|
|
77
|
+
content.to_s.split(",").each_with_object([]) do |piece, list|
|
|
78
|
+
if list.last&.match?(DATE_HEAD)
|
|
79
|
+
list[-1] = "#{list.last},#{piece.rstrip}"
|
|
80
|
+
else
|
|
81
|
+
list << piece.strip
|
|
82
|
+
end
|
|
83
|
+
end.reject(&:empty?)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param name [String] lower-case meta name
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def self.crawler_name?(name)
|
|
89
|
+
CRAWLER_META_NAMES.include?(name) || Crawlers::ALL.any? { |crawler| crawler.user_agent.casecmp?(name) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def initialize
|
|
93
|
+
@global = Set.new
|
|
94
|
+
@scoped = {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param scope [String, nil] crawler token; nil => every crawler
|
|
98
|
+
# @param content [String, nil] comma-separated directives
|
|
99
|
+
# @return [self]
|
|
100
|
+
def add(scope, content)
|
|
101
|
+
target = scope ? (@scoped[scope.downcase] ||= Set.new) : @global
|
|
102
|
+
self.class.tokens(content).each do |raw|
|
|
103
|
+
directive = canonical(raw)
|
|
104
|
+
next if directive.empty?
|
|
105
|
+
|
|
106
|
+
directive == "none" ? target.merge(%w[noindex nofollow]) : target << directive
|
|
107
|
+
end
|
|
108
|
+
@scoped.delete(scope.downcase) if scope && target.empty?
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @param other [RobotsDirectives]
|
|
113
|
+
# @return [RobotsDirectives] a new object with both sets of directives; nothing is dropped
|
|
114
|
+
def merge(other)
|
|
115
|
+
self.class.new.tap do |result|
|
|
116
|
+
result.global.merge(global).merge(other.global)
|
|
117
|
+
[scoped, other.scoped].each do |source|
|
|
118
|
+
source.each { |bot, set| (result.scoped[bot] ||= Set.new).merge(set) }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [RobotsDirectives] copy without +index+/+follow+/+all+ (they restrict nothing)
|
|
124
|
+
def restrictive
|
|
125
|
+
self.class.new.tap do |result|
|
|
126
|
+
result.global.merge(global - PERMISSIVE)
|
|
127
|
+
scoped.each do |bot, set|
|
|
128
|
+
rest = set - PERMISSIVE
|
|
129
|
+
result.scoped[bot] = rest unless rest.empty?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param bot [String, nil] crawler token; nil => global directives only
|
|
135
|
+
# @return [Boolean]
|
|
136
|
+
def noindex?(bot = nil)
|
|
137
|
+
global.include?("noindex") || (!bot.nil? && scoped.fetch(bot.downcase, Set.new).include?("noindex"))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] true when any crawler is told noindex (globally or in its own scope)
|
|
141
|
+
def noindex_for_any?
|
|
142
|
+
global.include?("noindex") || scoped.each_value.any? { |set| set.include?("noindex") }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def empty?
|
|
147
|
+
global.empty? && scoped.empty?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @return [String] one +X-Robots-Tag+ value: global directives first (a scope runs to the end of the value,
|
|
151
|
+
# so unscoped ones must precede every +bot:+), then each crawler's
|
|
152
|
+
def to_header
|
|
153
|
+
parts = global.to_a
|
|
154
|
+
scoped.each { |bot, set| parts << "#{bot}: #{set.to_a.join(", ")}" unless set.empty? }
|
|
155
|
+
parts.join(", ")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def ==(other)
|
|
159
|
+
other.is_a?(RobotsDirectives) && global == other.global && scoped == other.scoped
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Lower-cases the directive name; a value after +:+ keeps its case (+unavailable_after: 25 Jun 2026+).
|
|
165
|
+
def canonical(raw)
|
|
166
|
+
name, value = raw.strip.split(":", 2)
|
|
167
|
+
value.nil? ? name.to_s.downcase : "#{name.downcase}:#{value}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# The one path normalization seo_geo uses wherever a path is compared with a robots rule or a
|
|
5
|
+
# declaration: robots.txt evaluation, {Publication}, {PathMatch} (noindex declarations, sitemap
|
|
6
|
+
# exclusions), llms.txt link filtering and the robots.txt denied groups.
|
|
7
|
+
#
|
|
8
|
+
# RFC 9309 §2.2.2 (with RFC 3986 §6.2.2): rules and paths are compared in percent-encoded form.
|
|
9
|
+
# - Escapes of unreserved characters are decoded (+/%61dmin+ == +/admin+).
|
|
10
|
+
# - Escapes of +$+ and +*+ are decoded too (RFC 9309 §2.2.3: +/foo-%24+ matches the path +/foo-$+); the robots
|
|
11
|
+
# pattern compiler splits wildcards/anchors before normalizing, so they stay literals there.
|
|
12
|
+
# - Every other escape stays encoded, upper-cased: +%2F+, +%3F+, +%23+... are not +/+, +?+, +#+.
|
|
13
|
+
# - Non-ASCII bytes, controls and spaces are percent-encoded (UTF-8 octets, upper case).
|
|
14
|
+
module RobotsPath
|
|
15
|
+
UNRESERVED = /[A-Za-z0-9\-._~]/
|
|
16
|
+
DECODED_LITERALS = %w[$ *].freeze
|
|
17
|
+
private_constant :UNRESERVED, :DECODED_LITERALS
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @param value [String] a path, path pattern piece or request target (query kept)
|
|
22
|
+
# @return [String] normalized ASCII
|
|
23
|
+
def normalize(value)
|
|
24
|
+
out = +""
|
|
25
|
+
bytes = value.to_s.b
|
|
26
|
+
index = 0
|
|
27
|
+
while index < bytes.bytesize
|
|
28
|
+
byte = bytes.getbyte(index)
|
|
29
|
+
if byte == 0x25 && bytes[index + 1, 2].to_s.match?(/\A\h\h\z/)
|
|
30
|
+
char = bytes[index + 1, 2].hex.chr
|
|
31
|
+
out << (char.match?(UNRESERVED) || DECODED_LITERALS.include?(char) ? char : "%#{bytes[index + 1, 2].upcase}")
|
|
32
|
+
index += 3
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
out << (byte == 0x25 || byte <= 0x20 || byte >= 0x7F ? format("%%%02X", byte) : byte.chr)
|
|
36
|
+
index += 1
|
|
37
|
+
end
|
|
38
|
+
out
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Request target of a path or absolute URL: scheme/authority and fragment removed, query kept, normalized.
|
|
42
|
+
# This is what robots.txt rules are matched against.
|
|
43
|
+
# @param value [String]
|
|
44
|
+
# @return [String]
|
|
45
|
+
def target(value)
|
|
46
|
+
raw = value.to_s.sub(%r{\A[a-z][a-z0-9+.\-]*://[^/?#]*}i, "").sub(/#.*\z/m, "")
|
|
47
|
+
raw = "/#{raw}" unless raw.start_with?("/")
|
|
48
|
+
normalize(raw)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Path of a path or absolute URL: like {.target} with the query dropped (+""+ => +"/"+). Declarations
|
|
52
|
+
# ({Configuration#noindex_paths}, sitemap exclusions, +add path, index:/noindex:+) compare this.
|
|
53
|
+
# @param value [String]
|
|
54
|
+
# @return [String]
|
|
55
|
+
def path(value)
|
|
56
|
+
target(value).sub(/\?.*\z/m, "")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The HTML page a published URL stands for: {.path}, then a +.md+ suffix mapped back through
|
|
60
|
+
# {Markdown::PathSuffix.html_path} (+/secret.md?x=1+ => +/secret+, +/index.md+ => +/+). The query is
|
|
61
|
+
# dropped before the suffix is looked at, so a query cannot hide it.
|
|
62
|
+
# @param value [String]
|
|
63
|
+
# @param markdown [Boolean] whether +.md+ URLs are served ({MarkdownOptions#enabled})
|
|
64
|
+
# @return [String]
|
|
65
|
+
def html_path(value, markdown: true)
|
|
66
|
+
plain = path(value)
|
|
67
|
+
markdown && plain.end_with?(".md") ? Markdown::PathSuffix.html_path(plain) : plain
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# {.html_path} with the query kept: what robots rules see for a published URL (+/x.md?preview=1+ =>
|
|
71
|
+
# +/x?preview=1+), so a query-specific +Disallow: /x?preview=1+ still applies. Declarations ignore the query
|
|
72
|
+
# anyway ({.path}).
|
|
73
|
+
# @param value [String]
|
|
74
|
+
# @param markdown [Boolean]
|
|
75
|
+
# @return [String]
|
|
76
|
+
def html_target(value, markdown: true)
|
|
77
|
+
query = target(value)[/\?.*\z/m]
|
|
78
|
+
"#{html_path(value, markdown: markdown)}#{query}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fully percent-decoded form (UTF-8) of {.path}, for user Regexps written against readable paths
|
|
82
|
+
# (+%r{\A/가이드}+); nil when the bytes are not valid UTF-8.
|
|
83
|
+
# @param value [String]
|
|
84
|
+
# @return [String, nil]
|
|
85
|
+
def decoded(value)
|
|
86
|
+
text = path(value).gsub(/(?:%\h\h)+/) { |chunk| [chunk.delete("%")].pack("H*") }.force_encoding(Encoding::UTF_8)
|
|
87
|
+
text.valid_encoding? ? text : nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Compiles one robots.txt path pattern: a raw +*+ is a wildcard, a raw trailing +$+ an end anchor, escaped
|
|
91
|
+
# ones (+%2A+, +%24+) are literals.
|
|
92
|
+
# @param pattern [String]
|
|
93
|
+
# @return [Array(Regexp, Integer)] matcher over {.normalize}d targets and the length used for precedence
|
|
94
|
+
def compile(pattern)
|
|
95
|
+
anchored = pattern.end_with?("$")
|
|
96
|
+
body = anchored ? pattern[0...-1] : pattern
|
|
97
|
+
literals = body.split("*", -1).map { |part| normalize(part) }
|
|
98
|
+
regex = Regexp.new("\\A#{literals.map { |part| Regexp.escape(part) }.join(".*")}#{anchored ? "\\z" : ""}")
|
|
99
|
+
[regex, literals.join("*").length + (anchored ? 1 : 0)]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# RFC 9309 §2.2.2 rule choice: the longest matching pattern wins, +Allow+ wins a tie, empty patterns are
|
|
103
|
+
# ignored.
|
|
104
|
+
# @param rules [Array<Array(Symbol, String)>] +[:allow | :disallow, pattern]+
|
|
105
|
+
# @param value [String] path or URL ({.target} is applied)
|
|
106
|
+
# @return [Array(Symbol, String), nil] the deciding rule, nil when none matches
|
|
107
|
+
def decide(rules, value)
|
|
108
|
+
candidate = target(value)
|
|
109
|
+
rules.reject { |_, pattern| pattern.to_s.empty? }
|
|
110
|
+
.map { |kind, pattern| [kind, pattern, compile(pattern)] }
|
|
111
|
+
.select { |_, _, (regex, _)| regex.match?(candidate) }
|
|
112
|
+
.max_by { |kind, _, (_, length)| [length, kind == :allow ? 1 : 0] }
|
|
113
|
+
&.first(2)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param allow [Array<String>]
|
|
117
|
+
# @param disallow [Array<String>]
|
|
118
|
+
# @param value [String]
|
|
119
|
+
# @return [Boolean] true when the rules disallow +value+
|
|
120
|
+
def blocked?(allow, disallow, value)
|
|
121
|
+
rule = decide(allow.map { |pattern| [:allow, pattern] } + disallow.map { |pattern| [:disallow, pattern] }, value)
|
|
122
|
+
!rule.nil? && rule.first == :disallow
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "seo_geo"
|
|
4
|
+
require_relative "testing/environment"
|
|
5
|
+
|
|
6
|
+
module SeoGeo
|
|
7
|
+
# RSpec matchers. Plain matcher objects — seo_geo does not depend on rspec; when RSpec is
|
|
8
|
+
# loaded, requiring this file includes the matchers into request specs.
|
|
9
|
+
#
|
|
10
|
+
# Each matcher accepts a response (anything with +#body+) or a String body.
|
|
11
|
+
#
|
|
12
|
+
# @example spec/requests/seo_spec.rb
|
|
13
|
+
# require "seo_geo/rspec"
|
|
14
|
+
#
|
|
15
|
+
# it "folds the alias host" do
|
|
16
|
+
# host! "example.com"
|
|
17
|
+
# get "/pricing"
|
|
18
|
+
# expect(response).to have_seo_geo_canonical("https://www.example.com/pricing")
|
|
19
|
+
# expect(response).to have_seo_geo_single_title
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# it "keeps the FAQ honest" do
|
|
23
|
+
# get "/"
|
|
24
|
+
# expect(response).to have_seo_geo_faq_matching_visible_text
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# it "serves a valid sitemap" do
|
|
28
|
+
# get "/sitemap.xml"
|
|
29
|
+
# expect(response).to be_seo_geo_valid_sitemap(origin: "https://www.example.com")
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# it "serves production robots" do
|
|
33
|
+
# with_seo_geo_environment("production") { get "/robots.txt" }
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# it "keeps bots out of /admin" do
|
|
37
|
+
# get "/robots.txt"
|
|
38
|
+
# expect(response).not_to seo_geo_robots_allow("GPTBot", "/admin")
|
|
39
|
+
# end
|
|
40
|
+
module RSpec
|
|
41
|
+
# Base for the matchers below.
|
|
42
|
+
class Matcher
|
|
43
|
+
attr_reader :problems
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@problems = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param actual [#body, String]
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def matches?(actual)
|
|
52
|
+
@problems = check(actual.respond_to?(:body) ? actual.body.to_s : actual.to_s)
|
|
53
|
+
@problems.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def does_not_match?(actual)
|
|
57
|
+
!matches?(actual)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def failure_message
|
|
61
|
+
"expected #{description}, but: #{problems.join("; ")}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def failure_message_when_negated
|
|
65
|
+
"expected not #{description}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# +<link rel="canonical">+ equals the expected URL.
|
|
70
|
+
class CanonicalMatcher < Matcher
|
|
71
|
+
def initialize(expected)
|
|
72
|
+
super()
|
|
73
|
+
@expected = expected
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def description
|
|
77
|
+
"canonical #{@expected}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def check(body)
|
|
83
|
+
actual = Audit::HtmlDocument.new(body).canonical
|
|
84
|
+
actual == @expected ? [] : ["canonical was #{actual.inspect}"]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Exactly one +<title>+.
|
|
89
|
+
class SingleTitleMatcher < Matcher
|
|
90
|
+
def description
|
|
91
|
+
"exactly one <title>"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def check(body)
|
|
97
|
+
Audit::Checks.title_count(Audit::HtmlDocument.new(body))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# FAQPage JSON-LD exists and every question/answer is in the visible text.
|
|
102
|
+
class FaqMatcher < Matcher
|
|
103
|
+
def description
|
|
104
|
+
"FAQPage JSON-LD matching the visible FAQ"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def check(body)
|
|
110
|
+
doc = Audit::HtmlDocument.new(body)
|
|
111
|
+
return ["no FAQPage JSON-LD"] if doc.faq_items.empty?
|
|
112
|
+
|
|
113
|
+
Audit::Checks.faq_visible(doc)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Well-formed sitemap (urlset or sitemapindex) whose URLs are on +origin+.
|
|
118
|
+
class SitemapMatcher < Matcher
|
|
119
|
+
def initialize(origin)
|
|
120
|
+
super()
|
|
121
|
+
@origin = origin
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def description
|
|
125
|
+
"a valid sitemap#{" on #{@origin}" if @origin}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def check(body)
|
|
131
|
+
Audit::Checks.sitemap(body, origin: @origin && "#{@origin.chomp("/")}/").first
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# robots.txt lets +user_agent+ fetch +path+.
|
|
136
|
+
class RobotsMatcher < Matcher
|
|
137
|
+
def initialize(user_agent, path)
|
|
138
|
+
super()
|
|
139
|
+
@user_agent = user_agent
|
|
140
|
+
@path = path
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def description
|
|
144
|
+
"robots.txt to allow #{@user_agent} to fetch #{@path}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def failure_message_when_negated
|
|
148
|
+
"expected robots.txt to disallow #{@user_agent} #{@path}, but it is allowed"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def check(body)
|
|
154
|
+
evaluator = Robots::Evaluator.new(body)
|
|
155
|
+
return [] if evaluator.allowed?(@user_agent, @path)
|
|
156
|
+
|
|
157
|
+
rule = evaluator.matching_rule(@user_agent, @path)
|
|
158
|
+
["#{rule.first.to_s.capitalize}: #{rule.last} applies (group #{evaluator.group_token(@user_agent)})"]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Matcher methods; included into request specs automatically when RSpec is loaded.
|
|
163
|
+
module Matchers
|
|
164
|
+
include SeoGeo::Testing::EnvironmentHelper
|
|
165
|
+
# @param url [String]
|
|
166
|
+
def have_seo_geo_canonical(url) # rubocop:disable Naming/PredicateName
|
|
167
|
+
CanonicalMatcher.new(url)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def have_seo_geo_single_title # rubocop:disable Naming/PredicateName
|
|
171
|
+
SingleTitleMatcher.new
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def have_seo_geo_faq_matching_visible_text # rubocop:disable Naming/PredicateName
|
|
175
|
+
FaqMatcher.new
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @param origin [String, nil]
|
|
179
|
+
def be_seo_geo_valid_sitemap(origin: nil)
|
|
180
|
+
SitemapMatcher.new(origin)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @param user_agent [String]
|
|
184
|
+
# @param path [String]
|
|
185
|
+
def seo_geo_robots_allow(user_agent, path)
|
|
186
|
+
RobotsMatcher.new(user_agent, path)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if defined?(::RSpec) && ::RSpec.respond_to?(:configure)
|
|
193
|
+
::RSpec.configure do |config|
|
|
194
|
+
config.include SeoGeo::RSpec::Matchers, type: :request
|
|
195
|
+
config.include SeoGeo::RSpec::Matchers, type: :controller
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# Matches configuration Regexps (+og_image.param_constraints+, +noindex_paths+, +sitemap.exclude+) against
|
|
5
|
+
# request-controlled strings with a per-regexp timeout, so a backtracking pattern such as
|
|
6
|
+
# +/\A(a+)+\1\z/+ cannot hold a request worker. +Regexp.timeout+ is nil by default (Rails 7.2 included).
|
|
7
|
+
#
|
|
8
|
+
# Callers choose what a timeout means; seo_geo always resolves it to the restrictive answer
|
|
9
|
+
# (OG param => 400, noindex path => noindex, exclusion => excluded).
|
|
10
|
+
module SafeRegexp
|
|
11
|
+
# Seconds a single match may run.
|
|
12
|
+
TIMEOUT = 0.05
|
|
13
|
+
# Longer input is never handed to a configuration Regexp.
|
|
14
|
+
MAX_INPUT = 4096
|
|
15
|
+
|
|
16
|
+
# Raised (and only raised) for the input-length cap; Regexp::TimeoutError covers slow matches.
|
|
17
|
+
class TooLong < StandardError; end
|
|
18
|
+
|
|
19
|
+
@cache = {}
|
|
20
|
+
@lock = Mutex.new
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param regexp [Regexp]
|
|
25
|
+
# @param string [String]
|
|
26
|
+
# @raise [Regexp::TimeoutError, TooLong]
|
|
27
|
+
# @return [MatchData, nil]
|
|
28
|
+
def match(regexp, string)
|
|
29
|
+
raise TooLong, "input longer than #{MAX_INPUT} characters" if string.length > MAX_INPUT
|
|
30
|
+
|
|
31
|
+
bounded(regexp).match(string)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param regexp [Regexp]
|
|
35
|
+
# @param string [String]
|
|
36
|
+
# @param on_timeout [Boolean] answer when the match times out or the input is too long
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def match?(regexp, string, on_timeout:)
|
|
39
|
+
!match(regexp, string).nil?
|
|
40
|
+
rescue Regexp::TimeoutError, TooLong
|
|
41
|
+
on_timeout
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Regexp] a copy of +regexp+ carrying {TIMEOUT}, cached per source and options
|
|
45
|
+
def bounded(regexp)
|
|
46
|
+
key = [regexp.source, regexp.options]
|
|
47
|
+
@lock.synchronize { @cache[key] ||= Regexp.new(regexp.source, regexp.options, timeout: TIMEOUT) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|