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,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
module Sitemap
|
|
5
|
+
CHANGEFREQS = %w[always hourly daily weekly monthly yearly never].freeze
|
|
6
|
+
|
|
7
|
+
# One resolved +<url>+ entry. +loc+ is absolute; +lastmod+ is nil unless a real date was supplied
|
|
8
|
+
# (seo_geo never invents +Time.now+).
|
|
9
|
+
Url = Data.define(:loc, :lastmod, :priority, :changefreq)
|
|
10
|
+
|
|
11
|
+
# One registered source: a static path or a record collection.
|
|
12
|
+
#
|
|
13
|
+
# @!attribute source [String, #call, #find_each, #each] path ("/about"), a lambda returning a scope (evaluated per
|
|
14
|
+
# request: +-> { Post.published }+ — the recommended form, no model is loaded at boot and dev reloads never
|
|
15
|
+
# leave a stale class behind) or a scope / enumerable of records
|
|
16
|
+
# @!attribute batch [Boolean, nil] nil/true => +find_each+ when the scope has it (batches by primary key:
|
|
17
|
+
# +select+ must include +id+, +order+ is ignored); false => +each+, so the scope's own +order+ is kept
|
|
18
|
+
# @!attribute path [#call, Symbol, nil] record -> path; required for collections
|
|
19
|
+
# @!attribute lastmod [#call, Symbol, Time, Date, nil] date, record method name, or record -> date
|
|
20
|
+
# @!attribute priority [Float, nil] 0.0..1.0
|
|
21
|
+
# @!attribute changefreq [String, nil] one of {CHANGEFREQS}
|
|
22
|
+
# @!attribute hosts [Array<String>, nil] hosts whose sitemap lists the entry; nil => canonical host only
|
|
23
|
+
# @!attribute noindex [Boolean] declared noindex (static paths only): never listed in sitemap.xml or
|
|
24
|
+
# llms.txt, and the head helper renders robots noindex for it
|
|
25
|
+
# @!attribute index [Boolean] declared indexable under {Configuration#default_noindex}; without it a
|
|
26
|
+
# +default_noindex+ site lists nothing (see {SeoGeo::Publication})
|
|
27
|
+
# @!attribute match [Regexp, #call, nil] collections only: which request paths belong to the collection
|
|
28
|
+
# (+->(path) { ... }+ or a Regexp over the path). With +index: true+ it is what makes the head of a record
|
|
29
|
+
# page indexable under +default_noindex+ ({SeoGeo::Publication.declared_index?}); without it the head
|
|
30
|
+
# needs +seo index: true+ and +rake seo_geo:audit+ warns (+sitemap.collection_index+)
|
|
31
|
+
# @!attribute markdown [Boolean, nil] true when the page also serves Markdown at +/path.md+ (its action includes
|
|
32
|
+
# {SeoGeo::Markdownable}). The llms.txt sitemap fallback links +.md+ only for true; nil/false => HTML URL.
|
|
33
|
+
# false also makes +rake seo_geo:audit+ skip the page's Markdown checks.
|
|
34
|
+
Entry = Data.define(:source, :path, :lastmod, :priority, :changefreq, :hosts, :noindex, :index, :markdown, :match,
|
|
35
|
+
:batch) do
|
|
36
|
+
# @param config [Configuration]
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def markdown?(config)
|
|
39
|
+
config.markdown.suffix_urls? && markdown == true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def static?
|
|
44
|
+
source.is_a?(String)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param host [String] request host
|
|
48
|
+
# @param config [Configuration]
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def for_host?(host, config)
|
|
51
|
+
wanted = hosts ? hosts.map { |name| name.to_s.downcase } : [config.canonical_host]
|
|
52
|
+
wanted.include?(host.to_s.downcase.sub(/:\d+\z/, ""))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param path [String] request path
|
|
56
|
+
# @return [Boolean] true when +path+ is this entry's static path or matches its collection +match:+
|
|
57
|
+
# (a timed-out Regexp does not match: membership grants indexing, so failing safe means not granting it)
|
|
58
|
+
def covers?(path)
|
|
59
|
+
return RobotsPath.path(source) == RobotsPath.path(path) if static?
|
|
60
|
+
return false if match.nil?
|
|
61
|
+
return SafeRegexp.match?(match, RobotsPath.path(path), on_timeout: false) if match.is_a?(Regexp)
|
|
62
|
+
|
|
63
|
+
match.call(RobotsPath.path(path)) ? true : false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Boot-time registry of sitemap sources. Collections are stored unevaluated and enumerated per request.
|
|
68
|
+
#
|
|
69
|
+
# @example config/initializers/seo_geo.rb
|
|
70
|
+
# SeoGeo.sitemap.draw do
|
|
71
|
+
# add "/", priority: 1.0, changefreq: "daily"
|
|
72
|
+
# add -> { Presentation.published }, path: ->(p) { "/p/#{p.slug}" }, lastmod: :updated_at
|
|
73
|
+
# exclude %r{\A/s/}
|
|
74
|
+
# end
|
|
75
|
+
class Registry
|
|
76
|
+
# @return [Array<Entry>]
|
|
77
|
+
attr_reader :entries
|
|
78
|
+
# @return [Array<String, Regexp>]
|
|
79
|
+
attr_reader :exclusions
|
|
80
|
+
|
|
81
|
+
def initialize
|
|
82
|
+
@entries = []
|
|
83
|
+
@exclusions = []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Evaluates a block with this registry as +self+.
|
|
87
|
+
# @return [self]
|
|
88
|
+
def draw(&block)
|
|
89
|
+
instance_eval(&block) if block
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Registers a static path or a record collection.
|
|
94
|
+
# @param path_or_scope [String, #find_each, #each]
|
|
95
|
+
# @param path [#call, Symbol, nil] record -> path (collections only)
|
|
96
|
+
# @param lastmod [#call, Symbol, Time, Date, nil]
|
|
97
|
+
# @param priority [Numeric, nil] 0.0..1.0
|
|
98
|
+
# @param changefreq [String, Symbol, nil] one of {CHANGEFREQS}
|
|
99
|
+
# @param hosts [Array<String>, String, nil] nil => canonical host only
|
|
100
|
+
# @param noindex [Boolean] declare a static path noindex: it stays out of sitemap.xml and llms.txt and
|
|
101
|
+
# the head helper renders robots noindex for it (see also {Configuration#noindex_paths})
|
|
102
|
+
# @param index [Boolean] declare the entry indexable when {Configuration#default_noindex} is on
|
|
103
|
+
# (the registry counterpart of a controller's +seo index: true+)
|
|
104
|
+
# @param markdown [Boolean, nil] true => the llms.txt fallback links +/path.md+ (action includes
|
|
105
|
+
# {SeoGeo::Markdownable}); false => also skip the audit's Markdown checks; nil => HTML URL
|
|
106
|
+
# @param match [Regexp, #call, nil] collections only: Regexp or +->(path) { bool }+ telling which request
|
|
107
|
+
# paths are records of this collection, so +index: true+ reaches their head too
|
|
108
|
+
# @param batch [Boolean, nil] collections only: false iterates with +each+ (keeps +order+, loads all records);
|
|
109
|
+
# default uses +find_each+ when available (+select+ must include +id+, +order+ is ignored)
|
|
110
|
+
# @raise [ConfigurationError] on invalid arguments
|
|
111
|
+
# @return [Entry]
|
|
112
|
+
def add(path_or_scope, path: nil, lastmod: nil, priority: nil, changefreq: nil, hosts: nil, noindex: false, index: false,
|
|
113
|
+
markdown: nil, match: nil, batch: nil)
|
|
114
|
+
raise ConfigurationError, "noindex: is only valid for static paths" if noindex && !path_or_scope.is_a?(String)
|
|
115
|
+
raise ConfigurationError, "match: is only valid for collections" if match && path_or_scope.is_a?(String)
|
|
116
|
+
raise ConfigurationError, "batch: is only valid for collections" if !batch.nil? && path_or_scope.is_a?(String)
|
|
117
|
+
if match && !(match.is_a?(Regexp) || match.respond_to?(:call))
|
|
118
|
+
raise ConfigurationError, "match: must be a Regexp or respond to #call"
|
|
119
|
+
end
|
|
120
|
+
raise ConfigurationError, "noindex: and index: are mutually exclusive" if noindex && index
|
|
121
|
+
|
|
122
|
+
if path_or_scope.is_a?(String)
|
|
123
|
+
raise ConfigurationError, "sitemap path #{path_or_scope.inspect} must start with /" unless path_or_scope.start_with?("/")
|
|
124
|
+
raise ConfigurationError, "path: is only valid for collections" if path
|
|
125
|
+
else
|
|
126
|
+
unless path_or_scope.respond_to?(:call) || path_or_scope.respond_to?(:find_each) || path_or_scope.respond_to?(:each)
|
|
127
|
+
raise ConfigurationError, "sitemap source must be a path String, a lambda returning a scope, or respond to #find_each/#each"
|
|
128
|
+
end
|
|
129
|
+
raise ConfigurationError, "collections need path: (a callable or a record method name)" unless path
|
|
130
|
+
end
|
|
131
|
+
if priority && !(priority.is_a?(Numeric) && priority.between?(0, 1))
|
|
132
|
+
raise ConfigurationError, "sitemap priority must be between 0.0 and 1.0 (got #{priority.inspect})"
|
|
133
|
+
end
|
|
134
|
+
if changefreq && !CHANGEFREQS.include?(changefreq.to_s)
|
|
135
|
+
raise ConfigurationError, "sitemap changefreq must be one of #{CHANGEFREQS.join(", ")}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
entry = Entry.new(source: path_or_scope, path: path, lastmod: lastmod, priority: priority&.to_f,
|
|
139
|
+
changefreq: changefreq&.to_s, hosts: hosts && Array(hosts).map(&:to_s), noindex: noindex == true,
|
|
140
|
+
index: index == true, markdown: markdown.nil? ? nil : markdown == true, match: match,
|
|
141
|
+
batch: batch)
|
|
142
|
+
@entries << entry
|
|
143
|
+
entry
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Excludes paths from every sitemap (user-generated/share pages, duplicates).
|
|
147
|
+
# @param patterns [Array<String, Regexp>] exact paths or regexps matched against the path
|
|
148
|
+
# @return [void]
|
|
149
|
+
def exclude(*patterns)
|
|
150
|
+
@exclusions.concat(patterns.flatten)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @param path [String]
|
|
154
|
+
# @return [Boolean] true when a static entry for +path+ was added with +noindex: true+
|
|
155
|
+
def noindex?(path)
|
|
156
|
+
PathMatch.any?(@entries.select(&:noindex).map(&:source), path)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @param path [String]
|
|
160
|
+
# @param host [String, nil] request host; entries added with +hosts:+ count only there (nil => any host)
|
|
161
|
+
# @param config [Configuration] resolves +hosts: nil+ to the canonical host
|
|
162
|
+
# @return [Boolean] true when an entry added with +index: true+ covers +path+ on +host+: a static path, or
|
|
163
|
+
# a collection whose +match:+ accepts it
|
|
164
|
+
def index?(path, host: nil, config: SeoGeo.config)
|
|
165
|
+
@entries.any? { |entry| entry.index && (host.nil? || entry.for_host?(host, config)) && entry.covers?(path) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [Array<Entry>] collections added with +index: true+ but no +match:+ — their record pages are
|
|
169
|
+
# in sitemap.xml while the head cannot know they are declared indexable
|
|
170
|
+
def unmatched_index_collections
|
|
171
|
+
@entries.select { |entry| entry.index && !entry.static? && entry.match.nil? }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param path [String]
|
|
175
|
+
# @return [Boolean] true when a static entry for +path+ was added with +markdown: false+
|
|
176
|
+
def markdown_disabled?(path)
|
|
177
|
+
PathMatch.any?(@entries.select { |entry| entry.static? && entry.markdown == false }.map(&:source), path)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param path [String] compared like every declaration ({PathMatch}); a Regexp that times out excludes
|
|
181
|
+
# @return [Boolean]
|
|
182
|
+
def excluded?(path)
|
|
183
|
+
PathMatch.any?(@exclusions, path)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Enumerates resolved URLs for one host. The sitemaps controller streams these into XML,
|
|
187
|
+
# splitting into a sitemap index above 50,000 URLs.
|
|
188
|
+
# @param host [String] request host
|
|
189
|
+
# @param config [Configuration]
|
|
190
|
+
# @yieldparam url [Url]
|
|
191
|
+
# @return [Enumerator<Url>] when no block is given
|
|
192
|
+
def each_url(host, config: SeoGeo.config)
|
|
193
|
+
return enum_for(:each_url, host, config: config) unless block_given?
|
|
194
|
+
|
|
195
|
+
origin = config.origin_for(host)
|
|
196
|
+
entries.each do |entry|
|
|
197
|
+
next if entry.noindex || !entry.for_host?(host, config)
|
|
198
|
+
next if config.default_noindex && !entry.index
|
|
199
|
+
|
|
200
|
+
each_record(entry) do |record|
|
|
201
|
+
relative = record ? resolve(entry.path, record) : entry.source
|
|
202
|
+
next if relative.nil? || excluded?(relative.to_s) || config.noindex_path?(relative.to_s)
|
|
203
|
+
|
|
204
|
+
lastmod = entry.lastmod.is_a?(Symbol) || entry.lastmod.respond_to?(:call) ? resolve(entry.lastmod, record) : entry.lastmod
|
|
205
|
+
yield Url.new(loc: config.canonical_url("#{origin}#{relative}"), lastmod: lastmod,
|
|
206
|
+
priority: entry.priority, changefreq: entry.changefreq)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Removes every entry and exclusion (tests, code reloading).
|
|
212
|
+
# @return [void]
|
|
213
|
+
def clear
|
|
214
|
+
@entries.clear
|
|
215
|
+
@exclusions.clear
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def each_record(entry, &block)
|
|
221
|
+
return yield(nil) if entry.static?
|
|
222
|
+
|
|
223
|
+
source = entry.source.respond_to?(:call) ? entry.source.call : entry.source
|
|
224
|
+
if entry.batch != false && source.respond_to?(:find_each)
|
|
225
|
+
source.find_each(&block)
|
|
226
|
+
else
|
|
227
|
+
source.each(&block)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def resolve(callable_or_name, record)
|
|
232
|
+
return nil if callable_or_name.nil? || record.nil?
|
|
233
|
+
return record.public_send(callable_or_name) if callable_or_name.is_a?(Symbol)
|
|
234
|
+
|
|
235
|
+
callable_or_name.call(record)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module SeoGeo
|
|
7
|
+
module Sitemap
|
|
8
|
+
# Renders the sitemap for one request host from {Registry#each_url}.
|
|
9
|
+
#
|
|
10
|
+
# Up to {MAX_URLS} URLs => one +<urlset>+ at /sitemap.xml. Above that, /sitemap.xml is a
|
|
11
|
+
# +<sitemapindex>+ pointing at /sitemap-1.xml, /sitemap-2.xml, ... (sitemaps.org limit: 50,000 per file).
|
|
12
|
+
# URLs {SeoGeo::Publication} rejects are dropped (robots-disallowed, excluded, declared noindex,
|
|
13
|
+
# not opted in under +default_noindex+).
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# xml = SeoGeo::Sitemap::Xml.new(SeoGeo.config, SeoGeo.sitemap, "www.example.com")
|
|
17
|
+
# xml.render(nil) # => String, or nil when the host has no sitemap
|
|
18
|
+
class Xml
|
|
19
|
+
MAX_URLS = 50_000
|
|
20
|
+
XMLNS = "http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
21
|
+
|
|
22
|
+
# @param config [Configuration]
|
|
23
|
+
# @param registry [Registry]
|
|
24
|
+
# @param host [String] request host
|
|
25
|
+
# @param max_urls [Integer] URLs per file (tests may lower it)
|
|
26
|
+
def initialize(config, registry, host, max_urls: MAX_URLS)
|
|
27
|
+
@config = config
|
|
28
|
+
@registry = registry
|
|
29
|
+
@host = host
|
|
30
|
+
@max_urls = max_urls
|
|
31
|
+
@policy = config.host_policy(host)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param page [Integer, nil] nil => /sitemap.xml; n => /sitemap-n.xml
|
|
35
|
+
# @return [String, nil] XML, or nil when there is nothing to serve (=> 404)
|
|
36
|
+
def render(page = nil)
|
|
37
|
+
return nil unless @policy.sitemap
|
|
38
|
+
|
|
39
|
+
page.nil? ? render_root : render_part(page)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Enumerator<Url>] URLs this host's sitemap lists
|
|
43
|
+
def urls
|
|
44
|
+
Enumerator.new do |yielder|
|
|
45
|
+
@registry.each_url(@host, config: @config) { |url| yielder << url if listable?(url) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def render_root
|
|
52
|
+
head = urls.take(@max_urls + 1)
|
|
53
|
+
return nil if head.empty?
|
|
54
|
+
return urlset(head) if head.length <= @max_urls
|
|
55
|
+
|
|
56
|
+
sitemap_index
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def render_part(page)
|
|
60
|
+
return nil unless page.is_a?(Integer) && page.positive?
|
|
61
|
+
|
|
62
|
+
chunk = []
|
|
63
|
+
split = false
|
|
64
|
+
urls.each_with_index do |url, index|
|
|
65
|
+
split = true if index == @max_urls
|
|
66
|
+
next unless index / @max_urls == page - 1
|
|
67
|
+
chunk << url
|
|
68
|
+
end
|
|
69
|
+
# Parts exist only when the root is an index; /sitemap-1.xml of a small site is a 404.
|
|
70
|
+
split && !chunk.empty? ? urlset(chunk) : nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sitemap_index
|
|
74
|
+
parts = []
|
|
75
|
+
urls.each_slice(@max_urls) { |slice| parts << slice.filter_map { |url| url.lastmod && as_time(url.lastmod) }.max }
|
|
76
|
+
origin = @config.origin_for(@host)
|
|
77
|
+
body = parts.each_with_index.map do |lastmod, index|
|
|
78
|
+
lines = [" <sitemap>", " <loc>#{escape("#{origin}/sitemap-#{index + 1}.xml")}</loc>"]
|
|
79
|
+
lines << " <lastmod>#{format_time(lastmod)}</lastmod>" if lastmod
|
|
80
|
+
lines << " </sitemap>"
|
|
81
|
+
lines.join("\n")
|
|
82
|
+
end
|
|
83
|
+
document("sitemapindex", body)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def urlset(list)
|
|
87
|
+
body = list.map do |url|
|
|
88
|
+
lines = [" <url>", " <loc>#{escape(url.loc)}</loc>"]
|
|
89
|
+
lines << " <lastmod>#{format_time(url.lastmod)}</lastmod>" if url.lastmod
|
|
90
|
+
lines << " <changefreq>#{url.changefreq}</changefreq>" if url.changefreq
|
|
91
|
+
lines << " <priority>#{format("%.1f", url.priority)}</priority>" if url.priority
|
|
92
|
+
lines << " </url>"
|
|
93
|
+
lines.join("\n")
|
|
94
|
+
end
|
|
95
|
+
document("urlset", body)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def document(root, body)
|
|
99
|
+
[%(<?xml version="1.0" encoding="UTF-8"?>), %(<#{root} xmlns="#{XMLNS}">), *body, "</#{root}>", ""].join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# {SeoGeo::Publication} decides (robots disallow, exclusions, noindex declarations,
|
|
103
|
+
# default_noindex) — the same predicate llms.txt and the head use.
|
|
104
|
+
def listable?(url)
|
|
105
|
+
# Registry#each_url already skipped entries without index: true under default_noindex.
|
|
106
|
+
Publication.publishable?(@host, url.loc, config: @config, sitemap: @registry, check_host: false, declared_index: true)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Dates stay dates (W3C "YYYY-MM-DD"); times become W3C datetimes.
|
|
110
|
+
def format_time(value)
|
|
111
|
+
return value.to_s if value.is_a?(String)
|
|
112
|
+
return value.iso8601 if value.is_a?(Date) && !value.is_a?(DateTime)
|
|
113
|
+
|
|
114
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def as_time(value)
|
|
118
|
+
case value
|
|
119
|
+
when DateTime then value.to_time
|
|
120
|
+
when Date then Time.utc(value.year, value.month, value.day)
|
|
121
|
+
when String then parse_time(value)
|
|
122
|
+
else value.respond_to?(:to_time) ? value.to_time : nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_time(value)
|
|
127
|
+
Time.iso8601(value)
|
|
128
|
+
rescue ArgumentError
|
|
129
|
+
date = Date.iso8601(value) rescue nil # rubocop:disable Style/RescueModifier
|
|
130
|
+
date && Time.utc(date.year, date.month, date.day)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def escape(text)
|
|
134
|
+
text.to_s.encode(xml: :text)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
module Testing
|
|
5
|
+
# Runs the block as if the app were in another environment ({Configuration#environment}), then restores it,
|
|
6
|
+
# so a test can see production robots.txt, indexable heads and sitemaps. Shared by the Minitest assertions
|
|
7
|
+
# ({Assertions#with_seo_geo_environment}) and the RSpec helpers.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# SeoGeo::Testing.with_environment("production") { get "/robots.txt" }
|
|
11
|
+
# @param name [String, Symbol]
|
|
12
|
+
# @param config [Configuration]
|
|
13
|
+
# @return [Object] the block's value
|
|
14
|
+
def self.with_environment(name, config: SeoGeo.config)
|
|
15
|
+
saved = config.environment
|
|
16
|
+
config.environment = name.to_s
|
|
17
|
+
yield
|
|
18
|
+
ensure
|
|
19
|
+
config.environment = saved
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Instance helper mixed into Minitest tests and RSpec examples.
|
|
23
|
+
module EnvironmentHelper
|
|
24
|
+
# @param name [String, Symbol]
|
|
25
|
+
# @return [Object] the block's value
|
|
26
|
+
def with_seo_geo_environment(name, &block)
|
|
27
|
+
Testing.with_environment(name, &block)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "seo_geo"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "testing/environment"
|
|
6
|
+
|
|
7
|
+
module SeoGeo
|
|
8
|
+
# Minitest assertions for an app's integration tests. They use the same checks as
|
|
9
|
+
# +rake seo_geo:audit+ ({Audit::Checks}), so a passing test and a clean audit agree.
|
|
10
|
+
#
|
|
11
|
+
# @example test/integration/seo_test.rb
|
|
12
|
+
# require "seo_geo/testing"
|
|
13
|
+
#
|
|
14
|
+
# class SeoTest < ActionDispatch::IntegrationTest
|
|
15
|
+
# include SeoGeo::Testing::Assertions
|
|
16
|
+
#
|
|
17
|
+
# test("alias host folds") { assert_seo_geo_canonical "/pricing", host: "example.com" }
|
|
18
|
+
# test("faq is visible") { assert_seo_geo_faq_matches_visible "/" }
|
|
19
|
+
# test("one title") { assert_seo_geo_single_title "/" }
|
|
20
|
+
# test("sitemap") { assert_seo_geo_sitemap host: "www.example.com" }
|
|
21
|
+
# test("no alias sitemap") { assert_seo_geo_no_sitemap host: "example.com" }
|
|
22
|
+
# test("admin is closed") { refute_seo_geo_robots_allows "GPTBot", "/admin", host: "www.example.com" }
|
|
23
|
+
# test("production robots") { with_seo_geo_environment("production") { assert_seo_geo_sitemap host: "www.example.com" } }
|
|
24
|
+
# end
|
|
25
|
+
module Testing
|
|
26
|
+
# Include into +ActionDispatch::IntegrationTest+ (needs +host!+, +get+ and +response+).
|
|
27
|
+
# Each assertion takes a +path+ and requests it; pass +path = nil+ to check the last response.
|
|
28
|
+
module Assertions
|
|
29
|
+
include EnvironmentHelper
|
|
30
|
+
# The page's +<link rel="canonical">+ equals +expected+ (default: {Configuration#canonical_url} of the
|
|
31
|
+
# requested URL — on an alias host that is the canonical origin).
|
|
32
|
+
# @param path [String, nil]
|
|
33
|
+
# @param host [String, nil]
|
|
34
|
+
# @param expected [String, nil]
|
|
35
|
+
def assert_seo_geo_canonical(path, host: nil, expected: nil)
|
|
36
|
+
seo_geo_get(path, host)
|
|
37
|
+
expected ||= SeoGeo.config.canonical_url(request.url)
|
|
38
|
+
actual = Audit::HtmlDocument.new(response.body).canonical
|
|
39
|
+
assert_equal expected, actual, "canonical of #{request.url}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Exactly one +<title>+ in the head.
|
|
43
|
+
# @param path [String, nil]
|
|
44
|
+
# @param host [String, nil]
|
|
45
|
+
def assert_seo_geo_single_title(path = nil, host: nil)
|
|
46
|
+
seo_geo_get(path, host)
|
|
47
|
+
problems = Audit::Checks.title_count(Audit::HtmlDocument.new(response.body))
|
|
48
|
+
assert_empty problems, "#{request.url}: #{problems.join("; ")}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Every FAQPage question and answer in the JSON-LD also appears in the visible text (or the
|
|
52
|
+
# crawler fallback body), and at least one FAQPage exists.
|
|
53
|
+
# @param path [String, nil]
|
|
54
|
+
# @param host [String, nil]
|
|
55
|
+
def assert_seo_geo_faq_matches_visible(path = nil, host: nil)
|
|
56
|
+
seo_geo_get(path, host)
|
|
57
|
+
doc = Audit::HtmlDocument.new(response.body)
|
|
58
|
+
refute_empty doc.faq_items, "#{request.url} has no FAQPage JSON-LD"
|
|
59
|
+
problems = Audit::Checks.faq_visible(doc)
|
|
60
|
+
assert_empty problems, "#{request.url}: #{problems.join("; ")}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# /sitemap.xml on +host+ is 200, well-formed, in the sitemaps.org namespace, and lists only URLs
|
|
64
|
+
# on the host's origin.
|
|
65
|
+
# @param host [String]
|
|
66
|
+
# @return [Array<String>] the +<loc>+ values
|
|
67
|
+
def assert_seo_geo_sitemap(host:)
|
|
68
|
+
seo_geo_get("/sitemap.xml", host)
|
|
69
|
+
assert_equal 200, response.status, "/sitemap.xml on #{host}"
|
|
70
|
+
problems, locs, = Audit::Checks.sitemap(response.body, origin: "#{SeoGeo.config.origin_for(host)}/")
|
|
71
|
+
assert_empty problems, "/sitemap.xml on #{host}: #{problems.join("; ")}"
|
|
72
|
+
locs
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# /sitemap.xml on +host+ is a 404 (alias, undeclared or non-indexable host).
|
|
76
|
+
# @param host [String]
|
|
77
|
+
def assert_seo_geo_no_sitemap(host:)
|
|
78
|
+
seo_geo_get("/sitemap.xml", host)
|
|
79
|
+
assert_equal 404, response.status, "/sitemap.xml on #{host} should be 404"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# robots.txt on +host+ lets +user_agent+ fetch +path+ (RFC 9309 evaluation, {Robots::Evaluator}).
|
|
83
|
+
# @param user_agent [String] token or full User-Agent header
|
|
84
|
+
# @param path [String]
|
|
85
|
+
# @param host [String, nil]
|
|
86
|
+
def assert_seo_geo_robots_allows(user_agent, path, host: nil)
|
|
87
|
+
assert seo_geo_robots(host).allowed?(user_agent, path), "robots.txt should allow #{user_agent} #{path}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# robots.txt on +host+ disallows +path+ for +user_agent+.
|
|
91
|
+
def refute_seo_geo_robots_allows(user_agent, path, host: nil)
|
|
92
|
+
refute seo_geo_robots(host).allowed?(user_agent, path), "robots.txt should disallow #{user_agent} #{path}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def seo_geo_robots(host)
|
|
98
|
+
seo_geo_get("/robots.txt", host)
|
|
99
|
+
assert_equal 200, response.status, "/robots.txt"
|
|
100
|
+
Robots::Evaluator.new(response.body)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def seo_geo_get(path, host)
|
|
104
|
+
return if path.nil?
|
|
105
|
+
|
|
106
|
+
host!(host) if host
|
|
107
|
+
get path
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module SeoGeo
|
|
6
|
+
# Fail-closed hosts are silent by design (noindex + +Disallow: /+), which also hides a misconfiguration: a proxy
|
|
7
|
+
# that drops the Host header (+127.0.0.1+), or an apex/www mismatch, deindexes the whole site. In an indexable
|
|
8
|
+
# environment the robots.txt controller and the head warn once per host per process.
|
|
9
|
+
module UndeclaredHost
|
|
10
|
+
MESSAGE = 'seo_geo: host %<host>s is not declared (canonical_origin/canonical_aliases/host); ' \
|
|
11
|
+
"serving noindex + Disallow: /. Check proxy Host headers."
|
|
12
|
+
|
|
13
|
+
@warned = Set.new
|
|
14
|
+
@lock = Mutex.new
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param host [String, nil] request host
|
|
19
|
+
# @param config [Configuration]
|
|
20
|
+
# @return [Boolean] true when this call logged the warning
|
|
21
|
+
def warn_once(host, config: SeoGeo.config)
|
|
22
|
+
return false unless config.indexable_environment?
|
|
23
|
+
return false if config.host_policy(host.to_s).known
|
|
24
|
+
|
|
25
|
+
key = host.to_s.downcase
|
|
26
|
+
return false unless @lock.synchronize { @warned.add?(key) }
|
|
27
|
+
|
|
28
|
+
message = format(MESSAGE, host: key.inspect)
|
|
29
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
30
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Forgets which hosts were reported (tests).
|
|
35
|
+
# @return [void]
|
|
36
|
+
def reset!
|
|
37
|
+
@lock.synchronize { @warned.clear }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/seo_geo.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "seo_geo/version"
|
|
4
|
+
require_relative "seo_geo/crawlers"
|
|
5
|
+
require_relative "seo_geo/safe_regexp"
|
|
6
|
+
require_relative "seo_geo/robots_path"
|
|
7
|
+
require_relative "seo_geo/robots_directives"
|
|
8
|
+
require_relative "seo_geo/undeclared_host"
|
|
9
|
+
require_relative "seo_geo/configuration"
|
|
10
|
+
require_relative "seo_geo/sitemap/registry"
|
|
11
|
+
require_relative "seo_geo/page"
|
|
12
|
+
# Head, JSON-LD, crawler fallback
|
|
13
|
+
require_relative "seo_geo/json_ld"
|
|
14
|
+
require_relative "seo_geo/meta"
|
|
15
|
+
require_relative "seo_geo/crawler_fallback"
|
|
16
|
+
require_relative "seo_geo/controller"
|
|
17
|
+
# Robots, sitemap, llms.txt
|
|
18
|
+
require_relative "seo_geo/robots"
|
|
19
|
+
require_relative "seo_geo/sitemap/xml"
|
|
20
|
+
require_relative "seo_geo/llms_txt"
|
|
21
|
+
# Markdown, IndexNow, OG image
|
|
22
|
+
require_relative "seo_geo/markdown/negotiation"
|
|
23
|
+
require_relative "seo_geo/markdown/document"
|
|
24
|
+
require_relative "seo_geo/markdown/path_suffix"
|
|
25
|
+
require_relative "seo_geo/markdownable"
|
|
26
|
+
require_relative "seo_geo/index_now"
|
|
27
|
+
require_relative "seo_geo/model"
|
|
28
|
+
require_relative "seo_geo/og_image/renderer"
|
|
29
|
+
require_relative "seo_geo/og_image/ferrum_backend"
|
|
30
|
+
# Noindex path matching, robots evaluator, audit, bot verification
|
|
31
|
+
require_relative "seo_geo/path_match"
|
|
32
|
+
require_relative "seo_geo/publication"
|
|
33
|
+
require_relative "seo_geo/robots/evaluator"
|
|
34
|
+
require_relative "seo_geo/audit"
|
|
35
|
+
require_relative "seo_geo/bot_verifier"
|
|
36
|
+
|
|
37
|
+
# SEO and GEO (Generative Engine Optimization: visibility in AI search and assistants) for Rails.
|
|
38
|
+
# Not a geolocation library.
|
|
39
|
+
module SeoGeo
|
|
40
|
+
class << self
|
|
41
|
+
# @return [Configuration]
|
|
42
|
+
def config
|
|
43
|
+
@config ||= Configuration.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @yieldparam config [Configuration]
|
|
47
|
+
# @return [Configuration]
|
|
48
|
+
def configure
|
|
49
|
+
yield config
|
|
50
|
+
config
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Sitemap::Registry]
|
|
54
|
+
def sitemap
|
|
55
|
+
@sitemap ||= Sitemap::Registry.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [PageRegistry]
|
|
59
|
+
def pages
|
|
60
|
+
@pages ||= PageRegistry.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param key [Symbol, String]
|
|
64
|
+
# @param locale [Symbol, String, nil]
|
|
65
|
+
# @return [Page]
|
|
66
|
+
def page(key, locale: nil)
|
|
67
|
+
pages.fetch(key, locale: locale)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Replaces configuration and registries with fresh instances (tests).
|
|
71
|
+
# @return [void]
|
|
72
|
+
def reset!
|
|
73
|
+
@config = Configuration.new
|
|
74
|
+
@sitemap = Sitemap::Registry.new
|
|
75
|
+
@pages = PageRegistry.new
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
require_relative "seo_geo/engine" if defined?(Rails::Engine)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :seo_geo do
|
|
4
|
+
# `rake seo_geo:audit` (requests every registered URL and reports problems).
|
|
5
|
+
# Set before :environment so the engine adds the audited hosts to config.hosts outside production
|
|
6
|
+
# (SeoGeo::Engine.allow_audit_hosts!). For `bin/rails runner`, export SEO_GEO_AUDIT=1 yourself.
|
|
7
|
+
task :audit_flag do
|
|
8
|
+
ENV["SEO_GEO_AUDIT"] = "1"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "Audit robots.txt, sitemap.xml, llms.txt and every sitemap page in-process " \
|
|
12
|
+
"(FORMAT=json, HOSTS=a.com,b.com, MAX_PAGES=200); exits 1 when there are errors"
|
|
13
|
+
task audit: %i[audit_flag environment] do
|
|
14
|
+
report = SeoGeo::Audit::Runner.new(
|
|
15
|
+
app: Rails.application,
|
|
16
|
+
root: Rails.root,
|
|
17
|
+
hosts: ENV["HOSTS"]&.split(",")&.map(&:strip)&.reject(&:empty?),
|
|
18
|
+
max_pages: Integer(ENV.fetch("MAX_PAGES", "200"), 10)
|
|
19
|
+
).run
|
|
20
|
+
puts ENV["FORMAT"].to_s.casecmp?("json") ? report.to_json : report.to_s
|
|
21
|
+
exit(1) if report.errors?
|
|
22
|
+
end
|
|
23
|
+
end
|