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,22 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
html, body { margin: 0; width: <%= width %>px; height: <%= height %>px; }
|
|
7
|
+
body {
|
|
8
|
+
box-sizing: border-box; padding: 72px 84px; display: flex; flex-direction: column; justify-content: flex-end;
|
|
9
|
+
background: #f5f5f7; color: #111; font-family: -apple-system, "Segoe UI", "Noto Sans KR", "Apple SD Gothic Neo", sans-serif;
|
|
10
|
+
word-break: keep-all; overflow-wrap: break-word;
|
|
11
|
+
}
|
|
12
|
+
.site { position: absolute; top: 72px; left: 84px; font-size: 32px; font-weight: 600; color: #555; }
|
|
13
|
+
h1 { margin: 0; font-size: 72px; line-height: 1.15; text-wrap: balance; }
|
|
14
|
+
p { margin: 24px 0 0; font-size: 34px; line-height: 1.4; color: #555; text-wrap: pretty; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<% if site.name %><div class="site"><%= h(site.name) %></div><% end %>
|
|
19
|
+
<h1><%= h(page&.heading || page&.title || site.name || key) %></h1>
|
|
20
|
+
<% if page&.description %><p><%= h(page.description) %></p><% end %>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module SeoGeo
|
|
6
|
+
module OgImage
|
|
7
|
+
# Built-in backend: renders an ERB template to HTML and screenshots it with headless Chrome via
|
|
8
|
+
# the optional ferrum gem. Add +gem "ferrum"+ and a Chrome/Chromium binary to use it.
|
|
9
|
+
#
|
|
10
|
+
# Template locals: +key+, +page+ (or nil), +params+, +site+ (SiteOptions), +width+, +height+.
|
|
11
|
+
# Everything interpolated must go through +h(...)+ — the template is HTML.
|
|
12
|
+
class FerrumBackend
|
|
13
|
+
DEFAULT_TEMPLATE = File.expand_path("card.html.erb", __dir__)
|
|
14
|
+
|
|
15
|
+
# @param config [Configuration]
|
|
16
|
+
# @param browser_options [Hash] extra options for +Ferrum::Browser.new+
|
|
17
|
+
def initialize(config: SeoGeo.config, browser_options: {})
|
|
18
|
+
@config = config
|
|
19
|
+
@browser_options = browser_options
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [String] PNG bytes
|
|
23
|
+
def call(key, page, params)
|
|
24
|
+
require "ferrum"
|
|
25
|
+
width = @config.og_image.width
|
|
26
|
+
height = @config.og_image.height
|
|
27
|
+
browser = Ferrum::Browser.new({ headless: true, window_size: [width, height], timeout: 15 }
|
|
28
|
+
.merge(@browser_options))
|
|
29
|
+
begin
|
|
30
|
+
# window_size includes browser chrome; the viewport must be exactly width x height.
|
|
31
|
+
browser.resize(width: width, height: height)
|
|
32
|
+
browser.go_to("about:blank")
|
|
33
|
+
browser.main_frame.content = html(key, page, params)
|
|
34
|
+
wait_for_assets(browser)
|
|
35
|
+
browser.screenshot(format: "png", encoding: :binary)
|
|
36
|
+
ensure
|
|
37
|
+
browser.quit
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Web fonts and images referenced by the template; a slow asset must not fail the card.
|
|
42
|
+
def wait_for_assets(browser)
|
|
43
|
+
browser.network.wait_for_idle(timeout: 5)
|
|
44
|
+
rescue Ferrum::TimeoutError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] the rendered HTML card
|
|
49
|
+
def html(key, page, params)
|
|
50
|
+
Template.new(File.read(@config.og_image.template || DEFAULT_TEMPLATE)).render(
|
|
51
|
+
key: key, page: page, params: params, site: @config.site,
|
|
52
|
+
width: @config.og_image.width, height: @config.og_image.height
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Minimal ERB context exposing locals and +h+.
|
|
57
|
+
class Template
|
|
58
|
+
include ERB::Util
|
|
59
|
+
|
|
60
|
+
def initialize(source)
|
|
61
|
+
@erb = ERB.new(source, trim_mode: "-")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def render(locals)
|
|
65
|
+
context = binding
|
|
66
|
+
locals.each { |name, value| context.local_variable_set(name, value) }
|
|
67
|
+
@erb.result(context)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module SeoGeo
|
|
7
|
+
# Open Graph images served at +GET /og/:key.png+.
|
|
8
|
+
#
|
|
9
|
+
# A backend is any callable +(key, page, params) -> String+ returning PNG bytes (a Satori
|
|
10
|
+
# sidecar, a vips composer, ...). Without one, the bundled {FerrumBackend} renders an ERB
|
|
11
|
+
# card when the optional ferrum gem is installed. Results are cached in +Rails.cache+ for
|
|
12
|
+
# +og_image.cache_ttl+ under a key that includes +og_image.template_version+; failures are never cached.
|
|
13
|
+
#
|
|
14
|
+
# Every cache miss costs a render (with ferrum, a Chrome launch), so the controller serves only
|
|
15
|
+
# {.resolve_key known keys} and {.validated_params constrained params}, and at most
|
|
16
|
+
# +og_image.max_concurrent+ renders run at once per process.
|
|
17
|
+
module OgImage
|
|
18
|
+
# Raised when no backend can render, or a backend returns something that is not a PNG.
|
|
19
|
+
class RenderError < StandardError; end
|
|
20
|
+
# Raised on a cache miss while +og_image.max_concurrent+ renders are already running.
|
|
21
|
+
class Busy < RenderError; end
|
|
22
|
+
|
|
23
|
+
SLOTS = Mutex.new
|
|
24
|
+
@active_renders = 0
|
|
25
|
+
|
|
26
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
|
|
27
|
+
KEY_FORMAT = /\A[a-zA-Z0-9_-]{1,100}\z/
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# @param key [String]
|
|
32
|
+
# @param page [Page, nil] page copy for +key+ when one is defined
|
|
33
|
+
# @param params [Hash{String => String}] permitted query parameters
|
|
34
|
+
# @param config [Configuration]
|
|
35
|
+
# @param cache [#fetch, nil] cache store; nil disables caching
|
|
36
|
+
# @raise [RenderError]
|
|
37
|
+
# @return [String] PNG bytes (binary)
|
|
38
|
+
# @raise [Busy] when every render slot is taken
|
|
39
|
+
def render(key, page: nil, params: {}, config: SeoGeo.config, cache: default_cache)
|
|
40
|
+
produce = -> { with_render_slot(config.og_image.max_concurrent) { render_uncached(key, page, params, config) } }
|
|
41
|
+
return produce.call if cache.nil?
|
|
42
|
+
|
|
43
|
+
cache.fetch(cache_key(key, page, params, config), expires_in: config.og_image.cache_ttl) { produce.call }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Route constraint for +GET /og/:key.png+. The engine is mounted at "/" before or after the app's routes,
|
|
47
|
+
# so a request it does not want must not match at all (a controller 404 does not cascade): +/og/alice.png+
|
|
48
|
+
# reaches the app's own +/og/:username.png+ unless OG images are enabled and +alice+ is a known key.
|
|
49
|
+
# The resolved value is kept in +env+ for the controller, so +key_resolver+ runs once per request.
|
|
50
|
+
#
|
|
51
|
+
# A +key_resolver+ that raises claims the request (+true+) and leaves the error in +env+: the controller then
|
|
52
|
+
# answers with the configured fallback (redirect to +og_image.fallback_url+, else 404) and logs once. Falling
|
|
53
|
+
# through instead would hand a resolver outage to whatever app route matches next — a 404/500 that preview bots
|
|
54
|
+
# cache, or the wrong image — and skip +fallback_url+ entirely.
|
|
55
|
+
module RouteConstraint
|
|
56
|
+
ENV_KEY = "seo_geo.og_key"
|
|
57
|
+
ERROR_KEY = "seo_geo.og_key_error"
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# @param request [ActionDispatch::Request]
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def matches?(request)
|
|
64
|
+
config = SeoGeo.config
|
|
65
|
+
key = request.path_parameters[:key].to_s
|
|
66
|
+
return false unless config.og_image.enabled? && KEY_FORMAT.match?(key)
|
|
67
|
+
|
|
68
|
+
resolved = OgImage.resolve_key(key, config)
|
|
69
|
+
request.env[ENV_KEY] = resolved if resolved
|
|
70
|
+
resolved ? true : false
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
request.env[ERROR_KEY] = e
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param key [String]
|
|
78
|
+
# @return [Page, true, nil] truthy when +key+ is a {SeoGeo.pages} key, listed in +og_image.keys+ or
|
|
79
|
+
# accepted by +og_image.key_resolver+ (whose {Page} is returned); nil => 404
|
|
80
|
+
def resolve_key(key, config)
|
|
81
|
+
return true if config.og_image.keys.any? { |name| name.to_s == key }
|
|
82
|
+
return true if SeoGeo.pages.keys.any? { |name| name.to_s == key }
|
|
83
|
+
|
|
84
|
+
config.og_image.key_resolver&.call(key) || nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param query [Hash] request query parameters
|
|
88
|
+
# @return [Hash{String => String}, nil] permitted params that carry a constraint; nil when any value
|
|
89
|
+
# is not a String, is longer than +og_image.max_param_length+ or fails its constraint
|
|
90
|
+
def validated_params(query, config)
|
|
91
|
+
options = config.og_image
|
|
92
|
+
constraints = options.param_constraints.transform_keys(&:to_s)
|
|
93
|
+
names = options.permitted_params.map(&:to_s).select { |name| constraints.key?(name) }
|
|
94
|
+
params = query.to_h.transform_keys(&:to_s).slice(*names)
|
|
95
|
+
valid = params.all? do |name, value|
|
|
96
|
+
value.is_a?(String) && value.length <= options.max_param_length && allowed_value?(constraints[name], value)
|
|
97
|
+
end
|
|
98
|
+
valid ? params : nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Boolean] whole-value match for a Regexp, membership for an Array. A Regexp that exceeds
|
|
102
|
+
# {SafeRegexp::TIMEOUT} rejects the value (=> 400) instead of holding the worker.
|
|
103
|
+
def allowed_value?(rule, value)
|
|
104
|
+
return rule.map(&:to_s).include?(value) if rule.is_a?(Array)
|
|
105
|
+
|
|
106
|
+
match = SafeRegexp.match(rule, value)
|
|
107
|
+
!match.nil? && match.begin(0).zero? && match.end(0) == value.length
|
|
108
|
+
rescue Regexp::TimeoutError, SafeRegexp::TooLong
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Runs the block in one of +limit+ process-wide slots; never waits.
|
|
113
|
+
# @raise [Busy] when all slots are taken
|
|
114
|
+
def with_render_slot(limit)
|
|
115
|
+
acquired = SLOTS.synchronize do
|
|
116
|
+
next false if OgImage.active_renders >= limit
|
|
117
|
+
|
|
118
|
+
OgImage.active_renders += 1
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
raise Busy, "#{limit} OG image renders already running" unless acquired
|
|
122
|
+
|
|
123
|
+
begin
|
|
124
|
+
yield
|
|
125
|
+
ensure
|
|
126
|
+
SLOTS.synchronize { OgImage.active_renders -= 1 }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class << self
|
|
131
|
+
# @return [Integer] renders running right now in this process
|
|
132
|
+
attr_accessor :active_renders
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [String] cache key covering template version, key, locale and permitted params
|
|
136
|
+
def cache_key(key, page, params, config)
|
|
137
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(params.to_h.sort))[0, 16]
|
|
138
|
+
["seo_geo/og", config.og_image.template_version, key, page&.locale, digest].compact.join("/")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [#call, nil] the configured backend, else ferrum when available
|
|
142
|
+
def backend(config)
|
|
143
|
+
config.og_image.backend || (config.og_image.ferrum_available? ? FerrumBackend.new(config: config) : nil)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [#fetch, nil] Rails.cache when Rails is loaded
|
|
147
|
+
def default_cache
|
|
148
|
+
defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def render_uncached(key, page, params, config)
|
|
152
|
+
renderer = backend(config)
|
|
153
|
+
raise RenderError, "no og_image.backend configured and the ferrum gem is not available" if renderer.nil?
|
|
154
|
+
|
|
155
|
+
bytes = renderer.call(key, page, params)
|
|
156
|
+
unless bytes.is_a?(String) && bytes.b.start_with?(PNG_SIGNATURE)
|
|
157
|
+
raise RenderError, "og_image backend returned #{bytes.class} instead of PNG bytes"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
bytes.b
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/seo_geo/page.rb
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module SeoGeo
|
|
6
|
+
# The single source of page copy. The same object feeds the crawler fallback HTML, JSON-LD
|
|
7
|
+
# (FAQPage must match visible text) and Inertia props, so the three can never drift.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute key [Symbol]
|
|
10
|
+
# @!attribute locale [Symbol, nil]
|
|
11
|
+
# @!attribute title [String, nil]
|
|
12
|
+
# @!attribute description [String, nil]
|
|
13
|
+
# @!attribute heading [String, nil] visible H1
|
|
14
|
+
# @!attribute intro [String, Array<String>, nil] lead paragraph, or several ({#intro_paragraphs})
|
|
15
|
+
# @!attribute features [Array<Hash{Symbol => String}>] each +{title:, description:}+
|
|
16
|
+
# @!attribute faq [Array<Hash{Symbol => String}>] each +{question:, answer:}+
|
|
17
|
+
# @!attribute extra [Hash{Symbol => Object}] any other keys from the source, passed through to {#props}
|
|
18
|
+
Page = Data.define(:key, :locale, :title, :description, :heading, :intro, :features, :faq, :extra) do
|
|
19
|
+
# Builds a page from a loosely shaped hash (YAML, I18n or Ruby). Accepts +q+/+a+ as FAQ aliases
|
|
20
|
+
# and +name+/+body+ as feature aliases.
|
|
21
|
+
# @param key [Symbol, String]
|
|
22
|
+
# @param data [Hash]
|
|
23
|
+
# @param locale [Symbol, nil]
|
|
24
|
+
# @return [Page]
|
|
25
|
+
def self.from_hash(key, data, locale: nil)
|
|
26
|
+
data = deep_copy(deep_symbolize(data || {}), freeze: true)
|
|
27
|
+
known = %i[title description heading intro features faq]
|
|
28
|
+
new(key: key.to_sym, locale: locale&.to_sym,
|
|
29
|
+
title: data[:title], description: data[:description], heading: data[:heading], intro: data[:intro],
|
|
30
|
+
features: Array(data[:features]).map { |item| normalize_feature(item) }.freeze,
|
|
31
|
+
faq: Array(data[:faq]).map { |item| normalize_faq(item) }.freeze,
|
|
32
|
+
extra: data.except(*known).freeze)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.deep_symbolize(value)
|
|
36
|
+
case value
|
|
37
|
+
when Hash then value.to_h { |name, inner| [name.to_sym, deep_symbolize(inner)] }
|
|
38
|
+
when Array then value.map { |inner| deep_symbolize(inner) }
|
|
39
|
+
else value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @api private
|
|
44
|
+
# Copies Hash/Array/String nodes so a Page never shares mutable state with its source or its callers.
|
|
45
|
+
# @param value [Object]
|
|
46
|
+
# @param freeze [Boolean] freeze every copied node (registry storage) or leave them mutable ({#props})
|
|
47
|
+
# @return [Object]
|
|
48
|
+
def self.deep_copy(value, freeze:)
|
|
49
|
+
copy = case value
|
|
50
|
+
when Hash then value.to_h { |name, inner| [deep_copy(name, freeze: freeze), deep_copy(inner, freeze: freeze)] }
|
|
51
|
+
when Array then value.map { |inner| deep_copy(inner, freeze: freeze) }
|
|
52
|
+
when String then value.dup
|
|
53
|
+
else return value
|
|
54
|
+
end
|
|
55
|
+
freeze ? copy.freeze : copy
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.normalize_feature(item)
|
|
59
|
+
return { title: item.to_s, description: nil }.freeze unless item.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
{ title: item[:title] || item[:name], description: item[:description] || item[:body] }.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.normalize_faq(item)
|
|
65
|
+
raise ConfigurationError, "faq items must be hashes with question/answer" unless item.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
{ question: item[:question] || item[:q], answer: item[:answer] || item[:a] }.freeze
|
|
68
|
+
end
|
|
69
|
+
private_class_method :deep_symbolize, :normalize_feature, :normalize_faq
|
|
70
|
+
|
|
71
|
+
# @return [Array<String>] +intro+ as paragraphs (a String is one paragraph; blanks dropped)
|
|
72
|
+
def intro_paragraphs
|
|
73
|
+
Array(intro).map(&:to_s).reject { |line| line.strip.empty? }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Shape (all keys always present, Symbol keys; +extra+ keys merged at the top level):
|
|
77
|
+
# { key: Symbol, locale: Symbol | nil, title: String | nil, description: String | nil, heading: String | nil,
|
|
78
|
+
# intro: String | Array<String> | nil (as written in the source),
|
|
79
|
+
# features: [{ title:, description: }], faq: [{ question:, answer: }], **extra }
|
|
80
|
+
# Sources may write FAQ items as +q+/+a+ and features as +name+/+body+; props always carry the canonical
|
|
81
|
+
# +question+/+answer+ and +title+/+description+.
|
|
82
|
+
# @return [Hash{Symbol => Object}] detached, mutable deep copy for Inertia props / JSON APIs — changing it
|
|
83
|
+
# never reaches the registry or other requests
|
|
84
|
+
def props
|
|
85
|
+
Page.deep_copy({ key: key, locale: locale, title: title, description: description, heading: heading,
|
|
86
|
+
intro: intro, features: features, faq: faq }.merge(extra), freeze: false)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Markdown rendering of the copy, used for Markdown negotiation on client-rendered pages.
|
|
90
|
+
# @return [String]
|
|
91
|
+
def to_markdown
|
|
92
|
+
parts = []
|
|
93
|
+
parts << "# #{heading || title}" if heading || title
|
|
94
|
+
parts.concat(intro_paragraphs)
|
|
95
|
+
unless features.empty?
|
|
96
|
+
parts << "## Features"
|
|
97
|
+
parts << features.map { |item| item[:description] ? "- **#{item[:title]}**: #{item[:description]}" : "- #{item[:title]}" }.join("\n")
|
|
98
|
+
end
|
|
99
|
+
unless faq.empty?
|
|
100
|
+
parts << "## FAQ"
|
|
101
|
+
faq.each { |item| parts << "### #{item[:question]}\n\n#{item[:answer]}" }
|
|
102
|
+
end
|
|
103
|
+
"#{parts.join("\n\n")}\n"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Loads {Page}s from three interchangeable sources, in this precedence: Ruby hash, I18n scope, YAML file.
|
|
108
|
+
#
|
|
109
|
+
# YAML files live at +config/seo_geo/pages/<key>.yml+. A file is either a flat attribute hash or a
|
|
110
|
+
# hash keyed by locale (+ko:+, +en:+), in which case the requested locale (or site.default_locale) is used.
|
|
111
|
+
#
|
|
112
|
+
# @example
|
|
113
|
+
# SeoGeo.pages.register(:pricing, title: "Pricing", faq: [{ q: "Free?", a: "Yes" }])
|
|
114
|
+
# SeoGeo.pages.register_i18n(:login, scope: "views.login.seo", faq: :numbered) # q1_q / q1_a ...
|
|
115
|
+
# SeoGeo.page(:home).props
|
|
116
|
+
class PageRegistry
|
|
117
|
+
# @return [Array<String>] directories searched for +<key>.yml+
|
|
118
|
+
attr_accessor :paths
|
|
119
|
+
|
|
120
|
+
def initialize
|
|
121
|
+
@paths = []
|
|
122
|
+
@hashes = {}
|
|
123
|
+
@i18n = {}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param key [Symbol, String]
|
|
127
|
+
# @param data [Hash] page attributes, or a hash keyed by locale
|
|
128
|
+
# @return [void]
|
|
129
|
+
def register(key, data = nil, **attributes)
|
|
130
|
+
@hashes[key.to_sym] = Page.deep_copy(data || attributes, freeze: true)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param key [Symbol, String]
|
|
134
|
+
# @param scope [String] I18n scope holding title/description/heading/intro/features/faq
|
|
135
|
+
# @param faq [Symbol] :list (an array under +faq+) or :numbered (+q1_q+/+q1_a+, +q2_q+/... keys)
|
|
136
|
+
# @return [void]
|
|
137
|
+
def register_i18n(key, scope:, faq: :list)
|
|
138
|
+
raise ConfigurationError, "faq: must be :list or :numbered" unless %i[list numbered].include?(faq)
|
|
139
|
+
|
|
140
|
+
@i18n[key.to_sym] = { scope: scope, faq: faq }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @param key [Symbol, String]
|
|
144
|
+
# @param locale [Symbol, String, nil] nil => I18n.locale when available, else site.default_locale
|
|
145
|
+
# @raise [KeyError] when no source defines the page
|
|
146
|
+
# @return [Page]
|
|
147
|
+
def fetch(key, locale: nil)
|
|
148
|
+
key = key.to_sym
|
|
149
|
+
locale = (locale || current_locale).to_sym
|
|
150
|
+
data = if @hashes.key?(key) then localized(@hashes[key], locale)
|
|
151
|
+
elsif @i18n.key?(key) then from_i18n(@i18n[key], locale)
|
|
152
|
+
else from_yaml(key, locale)
|
|
153
|
+
end
|
|
154
|
+
raise KeyError, "seo_geo page #{key.inspect} is not defined (looked in Ruby, I18n and #{paths.inspect})" if data.nil?
|
|
155
|
+
|
|
156
|
+
Page.from_hash(key, data, locale: locale)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Array<Symbol>] every key known to any source
|
|
160
|
+
def keys
|
|
161
|
+
yaml_keys = paths.flat_map { |dir| Dir[File.join(dir, "*.yml")].map { |file| File.basename(file, ".yml").to_sym } }
|
|
162
|
+
(@hashes.keys + @i18n.keys + yaml_keys).uniq
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @return [void]
|
|
166
|
+
def clear
|
|
167
|
+
@hashes.clear
|
|
168
|
+
@i18n.clear
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# The request-locale rule ({Configuration#request_locale}) shared with +<html lang>+ and +og:locale+, so a
|
|
174
|
+
# default fetch ({SeoGeo.page}, +seo_geo_page+, the crawler fallback) picks the same copy as the head.
|
|
175
|
+
def current_locale
|
|
176
|
+
SeoGeo.config.request_locale(defined?(I18n) && I18n.respond_to?(:locale) ? I18n.locale : nil)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def localized(data, locale)
|
|
180
|
+
data = Page.send(:deep_symbolize, data)
|
|
181
|
+
return data unless data.keys.any? { |name| name.to_s.match?(/\A[a-z]{2}(-[A-Za-z]{2,4})?\z/) && data[name].is_a?(Hash) }
|
|
182
|
+
|
|
183
|
+
data[locale] || data[SeoGeo.config.site.default_locale.to_sym]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def from_yaml(key, locale)
|
|
187
|
+
file = paths.map { |dir| File.join(dir, "#{key}.yml") }.find { |path| File.file?(path) }
|
|
188
|
+
file && localized(YAML.safe_load_file(file, aliases: true) || {}, locale)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def from_i18n(source, locale)
|
|
192
|
+
raw = I18n.t(source[:scope], locale: locale, default: nil)
|
|
193
|
+
return nil unless raw.is_a?(Hash)
|
|
194
|
+
return raw if source[:faq] == :list
|
|
195
|
+
|
|
196
|
+
numbered = raw.keys.filter_map { |name| name.to_s[/\Aq(\d+)_q\z/, 1]&.to_i }.sort
|
|
197
|
+
raw.reject { |name, _| name.to_s.match?(/\Aq\d+_[qa]\z/) }
|
|
198
|
+
.merge(faq: numbered.map { |n| { question: raw[:"q#{n}_q"], answer: raw[:"q#{n}_a"] } })
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# Declaration matching shared by {Configuration#noindex_path?} and {Sitemap::Registry} (+noindex?+,
|
|
5
|
+
# +index?+, +excluded?+). Paths and String declarations are compared as {RobotsPath.path} (query dropped,
|
|
6
|
+
# RFC 9309 normalization), so +/%61dmin+, +/admin?x=1+ and +/admin+ are one path.
|
|
7
|
+
module PathMatch
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param value [String] path or absolute URL, raw or percent-encoded
|
|
11
|
+
# @return [Array<String>] the normalized path and, when it differs, its fully decoded UTF-8 form
|
|
12
|
+
# (the forms a declaration Regexp is matched against)
|
|
13
|
+
def variants(value)
|
|
14
|
+
[RobotsPath.path(value), RobotsPath.decoded(value)].compact.uniq
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param patterns [Array<String, Regexp>] exact paths (normalized like the candidate) or regexps
|
|
18
|
+
# @param path [String]
|
|
19
|
+
# @return [Boolean] a Regexp that times out ({SafeRegexp}) counts as a match: every declaration
|
|
20
|
+
# restricts publication, so failing safe means restricting
|
|
21
|
+
def any?(patterns, path)
|
|
22
|
+
return false if patterns.nil? || patterns.empty?
|
|
23
|
+
|
|
24
|
+
plain = RobotsPath.path(path)
|
|
25
|
+
candidates = nil
|
|
26
|
+
patterns.any? do |pattern|
|
|
27
|
+
if pattern.is_a?(Regexp)
|
|
28
|
+
candidates ||= variants(path)
|
|
29
|
+
candidates.any? { |candidate| SafeRegexp.match?(pattern, candidate, on_timeout: true) }
|
|
30
|
+
else
|
|
31
|
+
RobotsPath.path(pattern) == plain
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# One answer to "may this path be published to crawlers on this host?". sitemap.xml, llms.txt,
|
|
5
|
+
# llms-full.txt and the head's robots meta all ask it, so they cannot disagree.
|
|
6
|
+
#
|
|
7
|
+
# A path is publishable on a host when all hold:
|
|
8
|
+
# - the host serves a sitemap ({HostPolicy#sitemap}: declared/canonical, indexable, indexable environment);
|
|
9
|
+
# - robots.txt does not disallow it for the host (RFC 9309 longest match over the host's
|
|
10
|
+
# +allow+/+disallow+ prefixes, Allow wins a tie);
|
|
11
|
+
# - it is not excluded with {Sitemap::Registry#exclude};
|
|
12
|
+
# - it is not declared noindex ({Configuration#noindex_paths}, +sitemap.add path, noindex: true+);
|
|
13
|
+
# - under {Configuration#default_noindex}, it is declared indexable (+sitemap.add path, index: true+ —
|
|
14
|
+
# the registry counterpart of a controller's +seo index: true+).
|
|
15
|
+
#
|
|
16
|
+
# Request-time +seo noindex: true+ is invisible here; +rake seo_geo:audit+ reports such pages
|
|
17
|
+
# (+sitemap.noindex+).
|
|
18
|
+
module Publication
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @param host [String] request host
|
|
22
|
+
# @param path [String] path or absolute URL; robots rules see the {RobotsPath.target} (query kept),
|
|
23
|
+
# declarations the {RobotsPath.path}. A +.md+ URL is judged as the page it is the Markdown of — callers
|
|
24
|
+
# pass {RobotsPath.html_path} for those.
|
|
25
|
+
# @param config [Configuration]
|
|
26
|
+
# @param sitemap [Sitemap::Registry]
|
|
27
|
+
# @param check_host [Boolean] false skips the host sitemap check (callers that already did it)
|
|
28
|
+
# @param declared_index [Boolean] true when the caller already knows the entry was added with
|
|
29
|
+
# +index: true+ (sitemap.xml, whose Registry#each_url only yields such entries under default_noindex)
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def publishable?(host, path, config: SeoGeo.config, sitemap: SeoGeo.sitemap, check_host: true, declared_index: false)
|
|
32
|
+
policy = config.host_policy(host)
|
|
33
|
+
return false if check_host && !policy.sitemap
|
|
34
|
+
return false if robots_blocked?(policy, path)
|
|
35
|
+
return false if sitemap.excluded?(path)
|
|
36
|
+
return false if declared_noindex?(path, config: config, sitemap: sitemap)
|
|
37
|
+
|
|
38
|
+
!config.default_noindex || declared_index || declared_index?(host, path, config: config, sitemap: sitemap)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Head robots meta: a declared-noindex path is noindex even when the controller calls +seo index: true+.
|
|
42
|
+
# Declarations apply on every host (restricting is the safe side).
|
|
43
|
+
# @param path [String]
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def declared_noindex?(path, config: SeoGeo.config, sitemap: SeoGeo.sitemap)
|
|
46
|
+
config.noindex_path?(path) || sitemap.noindex?(path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Head robots meta under +default_noindex+: an +index: true+ entry for this host covers the path — a static
|
|
50
|
+
# path, or a collection whose +match:+ accepts it ({Sitemap::Entry#covers?}). The same answer
|
|
51
|
+
# sitemap.xml and llms.txt use, so the head cannot be noindex for a listed page on one host and indexable
|
|
52
|
+
# on another.
|
|
53
|
+
# @param host [String] request host
|
|
54
|
+
# @param path [String]
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def declared_index?(host, path, config: SeoGeo.config, sitemap: SeoGeo.sitemap)
|
|
57
|
+
sitemap.index?(path, host: host, config: config)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# RFC 9309 §2.2.2 over the host policy's lists, through the same matcher as {Robots::Evaluator}.
|
|
61
|
+
# @param policy [HostPolicy]
|
|
62
|
+
# @param path [String]
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def robots_blocked?(policy, path)
|
|
65
|
+
RobotsPath.blocked?(policy.allow, policy.disallow, path)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
class Robots
|
|
5
|
+
# Reads a robots.txt body and answers "may this crawler fetch this path?" the way RFC 9309 says a
|
|
6
|
+
# crawler decides. Used by the test helpers and
|
|
7
|
+
# +rake seo_geo:audit+; it is never used for access decisions at request time.
|
|
8
|
+
#
|
|
9
|
+
# - Group choice (§2.2.1): the groups naming the crawler's product token (case-insensitive) are merged;
|
|
10
|
+
# only when none names it does the crawler fall back to the +*+ groups.
|
|
11
|
+
# - Rule choice (§2.2.2): the longest matching path wins; +Allow+ wins a tie; no matching rule => allowed.
|
|
12
|
+
# - Patterns: +*+ matches any sequence, a trailing +$+ anchors the end. An empty +Disallow:+ is ignored.
|
|
13
|
+
# Rules and paths are compared in {RobotsPath.normalize}d form: +/%61dmin+ is +/admin+, but an encoded
|
|
14
|
+
# reserved delimiter stays one (+/download%3Fpublic+ is not +/download?public+).
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# rules = SeoGeo::Robots::Evaluator.new(body)
|
|
18
|
+
# rules.allowed?("GPTBot", "/admin") # => false
|
|
19
|
+
# rules.allowed?("Mozilla/5.0 (compatible; GPTBot/1.2)", "/") # full User-Agent headers work too
|
|
20
|
+
class Evaluator
|
|
21
|
+
Group = Struct.new(:agents, :rules)
|
|
22
|
+
|
|
23
|
+
# @param body [String] robots.txt contents
|
|
24
|
+
def initialize(body)
|
|
25
|
+
@groups = []
|
|
26
|
+
current = nil
|
|
27
|
+
body.to_s.each_line do |raw|
|
|
28
|
+
line = raw.sub(/#.*/, "").strip
|
|
29
|
+
next if line.empty?
|
|
30
|
+
|
|
31
|
+
field, value = line.split(":", 2).map { |part| part.to_s.strip }
|
|
32
|
+
case field.downcase
|
|
33
|
+
when "user-agent"
|
|
34
|
+
current = nil if current && !current.rules.empty?
|
|
35
|
+
current ||= Group.new([], []).tap { |group| @groups << group }
|
|
36
|
+
current.agents << value.downcase
|
|
37
|
+
when "allow", "disallow"
|
|
38
|
+
current&.rules&.push([field.downcase.to_sym, value])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param user_agent [String] robots.txt product token ("GPTBot", "*") or a full User-Agent header
|
|
44
|
+
# @param path [String] path, optionally with a query string
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def allowed?(user_agent, path)
|
|
47
|
+
rule = matching_rule(user_agent, path)
|
|
48
|
+
rule.nil? || rule.first == :allow
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param user_agent [String]
|
|
52
|
+
# @param path [String]
|
|
53
|
+
# @return [Array(Symbol, String), nil] the deciding +[:allow | :disallow, pattern]+, nil when no rule matches
|
|
54
|
+
def matching_rule(user_agent, path)
|
|
55
|
+
RobotsPath.decide(rules_for(user_agent), path.to_s.empty? ? "/" : path.to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Array<String>] every agent token that has a group (lower case)
|
|
59
|
+
def agents
|
|
60
|
+
@groups.flat_map(&:agents)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param user_agent [String]
|
|
64
|
+
# @return [String] the group token the crawler obeys ("*" when no group names it)
|
|
65
|
+
def group_token(user_agent)
|
|
66
|
+
token_for(user_agent.to_s.downcase) || "*"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def rules_for(user_agent)
|
|
72
|
+
token = group_token(user_agent)
|
|
73
|
+
@groups.select { |group| group.agents.include?(token) }.flat_map(&:rules)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Exact token first; otherwise the longest group token contained in a full User-Agent header.
|
|
77
|
+
def token_for(needle)
|
|
78
|
+
named = agents.uniq - ["*"]
|
|
79
|
+
return needle if named.include?(needle)
|
|
80
|
+
|
|
81
|
+
named.select { |token| needle.include?(token) }.max_by(&:length)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|