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,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