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,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# Included into ActionController::Base by the engine (initializer "seo_geo.helpers").
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# class PostsController < ApplicationController
|
|
8
|
+
# def show
|
|
9
|
+
# @post = Post.find(params[:id])
|
|
10
|
+
# seo title: @post.title, description: @post.summary, image: @post.cover_url, type: "article"
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# class LandingController < ApplicationController
|
|
15
|
+
# def show
|
|
16
|
+
# render inertia: "Home", props: { seo: seo_geo_page(:home).props }
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
module Controller
|
|
20
|
+
SEO_KEYS = Meta::State.members.freeze
|
|
21
|
+
|
|
22
|
+
def self.included(base)
|
|
23
|
+
base.helper_method :seo_geo_state, :seo_geo_page, :seo_geo_markdown_available? if base.respond_to?(:helper_method)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Sets head values for this request. Later calls merge into earlier ones; +json_ld:+ appends.
|
|
27
|
+
#
|
|
28
|
+
# @param title [String] page title without the site suffix (title_template adds it)
|
|
29
|
+
# @param description [String]
|
|
30
|
+
# @param image [String, Hash] absolute or root-relative OG image URL (or +{url:}+)
|
|
31
|
+
# @param noindex [Boolean] true => robots noindex
|
|
32
|
+
# @param index [Boolean] true => opt this action into indexing when +default_noindex+ is on
|
|
33
|
+
# @param canonical [String, false] path or URL to canonicalize; false omits the canonical link and hreflang
|
|
34
|
+
# @param page [Symbol, Page] page copy (title/description/FAQ JSON-LD defaults)
|
|
35
|
+
# @param type [String] og:type (default "website")
|
|
36
|
+
# @param markdown [Boolean] false hides the Markdown alternate link
|
|
37
|
+
# @param json_ld [Hash, JsonLd, Array] extra JSON-LD nodes
|
|
38
|
+
# @return [Meta::State]
|
|
39
|
+
def seo(**options)
|
|
40
|
+
unknown = options.keys - SEO_KEYS
|
|
41
|
+
raise ArgumentError, "unknown seo option(s): #{unknown.join(", ")} (known: #{SEO_KEYS.join(", ")})" unless unknown.empty?
|
|
42
|
+
|
|
43
|
+
extra_json_ld = options.delete(:json_ld)
|
|
44
|
+
options.each { |name, value| seo_geo_state[name] = value }
|
|
45
|
+
seo_geo_state.json_ld.concat(extra_json_ld.is_a?(Array) ? extra_json_ld.flatten : [extra_json_ld]) if extra_json_ld
|
|
46
|
+
seo_geo_state
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Fetches the page copy and uses it for this request's head (title, description, FAQPage).
|
|
50
|
+
# The return value feeds the crawler fallback body and Inertia props: +seo_geo_page(:home).props+.
|
|
51
|
+
# @param key [Symbol, String]
|
|
52
|
+
# @param locale [Symbol, String, nil]
|
|
53
|
+
# @return [Page]
|
|
54
|
+
def seo_geo_page(key, locale: nil)
|
|
55
|
+
page = SeoGeo.page(key, locale: locale)
|
|
56
|
+
seo_geo_state.page = page
|
|
57
|
+
page
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether this action answers Markdown requests; {SeoGeo::Markdownable} overrides it per action.
|
|
61
|
+
# +seo_geo_head+ advertises the Markdown alternate only when this is true.
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def seo_geo_markdown_available?
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Meta::State]
|
|
68
|
+
def seo_geo_state
|
|
69
|
+
@seo_geo_state ||= Meta::State.new
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# Static page body for crawlers that do not run JavaScript (GPTBot, ClaudeBot, PerplexityBot, Yeti...)
|
|
5
|
+
# on client-rendered pages (Inertia, SPA). It renders the same {Page} copy as the Inertia props and
|
|
6
|
+
# the FAQPage JSON-LD. The head snippet from +seo_geo_head+ hides it while JavaScript runs and shows it
|
|
7
|
+
# again when the app has not called +window.SeoGeo.mounted()+ within five seconds.
|
|
8
|
+
#
|
|
9
|
+
# Markup lives in +app/views/seo_geo/fallback/_body.html.erb+ and +_faq.html.erb+; an app overrides
|
|
10
|
+
# it by adding the same paths under its own +app/views+.
|
|
11
|
+
module CrawlerFallback
|
|
12
|
+
# View helpers, included into ActionView by the engine (initializer "seo_geo.helpers").
|
|
13
|
+
module Helper
|
|
14
|
+
# Extra crawler-visible HTML goes at the end of +<main>+, from the block and/or
|
|
15
|
+
# +content_for(:seo_geo_fallback_extra)+ (block first).
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# <%= seo_geo_fallback_body(:home) do %>
|
|
19
|
+
# <p><a href="/pricing">Pricing</a></p>
|
|
20
|
+
# <% end %>
|
|
21
|
+
# @param key [Symbol, String, Page] page key or a fetched page
|
|
22
|
+
# @param locale [Symbol, String, nil]
|
|
23
|
+
# @return [ActiveSupport::SafeBuffer] +<main data-seo-fallback>+ with heading, intro, features, FAQ, extra
|
|
24
|
+
def seo_geo_fallback_body(key, locale: nil, &block)
|
|
25
|
+
extra = safe_join([block && capture(&block), content_for?(:seo_geo_fallback_extra) ? content_for(:seo_geo_fallback_extra) : nil].compact)
|
|
26
|
+
render(partial: "seo_geo/fallback/body", locals: { page: seo_geo_fallback_page(key, locale), extra: extra })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The FAQ section alone, for server-rendered (ERB) pages that show the FAQ themselves; it is the
|
|
30
|
+
# same text as the FAQPage JSON-LD.
|
|
31
|
+
# @param key [Symbol, String, Page]
|
|
32
|
+
# @param locale [Symbol, String, nil]
|
|
33
|
+
# @return [ActiveSupport::SafeBuffer]
|
|
34
|
+
def seo_geo_faq(key, locale: nil)
|
|
35
|
+
render(partial: "seo_geo/fallback/faq", locals: { page: seo_geo_fallback_page(key, locale) })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def seo_geo_fallback_page(key, locale)
|
|
41
|
+
key.is_a?(Page) ? key : SeoGeo.page(key, locale: locale)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# Canonical user-agent table, grouped by what the operator uses the fetch for.
|
|
5
|
+
#
|
|
6
|
+
# Purposes:
|
|
7
|
+
# - +:training+ — model training corpora. Blocking these never affects search inclusion.
|
|
8
|
+
# - +:search+ — search / answer-engine indexes (classic and AI search).
|
|
9
|
+
# - +:user+ — fetches triggered by a person asking an assistant about a URL.
|
|
10
|
+
# Several operators state robots.txt may not apply to these; see {Crawler#honors_robots}.
|
|
11
|
+
#
|
|
12
|
+
# Sources (checked 2026-10-14): developers.openai.com/api/docs/bots,
|
|
13
|
+
# support.claude.com/en/articles/8896518, docs.perplexity.ai/guides/bots,
|
|
14
|
+
# developers.google.com/search/docs/crawling-indexing/google-common-crawlers,
|
|
15
|
+
# support.apple.com/en-us/119829, developers.facebook.com/docs/sharing/webmasters/web-crawlers,
|
|
16
|
+
# commoncrawl.org/ccbot, searchadvisor.naver.com/guide/seo-basic-robots.
|
|
17
|
+
module Crawlers
|
|
18
|
+
PURPOSES = %i[training search user].freeze
|
|
19
|
+
|
|
20
|
+
# One robots.txt user-agent token.
|
|
21
|
+
#
|
|
22
|
+
# @!attribute user_agent [String] token as written in a robots.txt +User-agent:+ line
|
|
23
|
+
# @!attribute owner [String] operator name
|
|
24
|
+
# @!attribute purpose [Symbol] one of {PURPOSES}
|
|
25
|
+
# @!attribute honors_robots [Boolean, Symbol] true, or +:unreliable+ / +:may_ignore+ when the
|
|
26
|
+
# operator says (or reports show) robots.txt may not be applied
|
|
27
|
+
# @!attribute token_only [Boolean] true for control tokens that never fetch on their own
|
|
28
|
+
# (Google-Extended, Applebot-Extended); they only appear in robots.txt
|
|
29
|
+
Crawler = Data.define(:user_agent, :owner, :purpose, :honors_robots, :token_only)
|
|
30
|
+
|
|
31
|
+
def self.entry(user_agent, owner, purpose, honors_robots: true, token_only: false)
|
|
32
|
+
Crawler.new(user_agent: user_agent, owner: owner, purpose: purpose,
|
|
33
|
+
honors_robots: honors_robots, token_only: token_only).freeze
|
|
34
|
+
end
|
|
35
|
+
private_class_method :entry
|
|
36
|
+
|
|
37
|
+
# @return [Array<Crawler>] the full table, frozen
|
|
38
|
+
ALL = [
|
|
39
|
+
entry("GPTBot", "OpenAI", :training),
|
|
40
|
+
entry("ClaudeBot", "Anthropic", :training),
|
|
41
|
+
entry("Google-Extended", "Google", :training, token_only: true),
|
|
42
|
+
entry("Applebot-Extended", "Apple", :training, token_only: true),
|
|
43
|
+
entry("CCBot", "Common Crawl", :training),
|
|
44
|
+
entry("Meta-ExternalAgent", "Meta", :training),
|
|
45
|
+
entry("Bytespider", "ByteDance", :training, honors_robots: :unreliable),
|
|
46
|
+
|
|
47
|
+
entry("Googlebot", "Google", :search),
|
|
48
|
+
entry("Bingbot", "Microsoft", :search),
|
|
49
|
+
entry("Yeti", "Naver", :search),
|
|
50
|
+
entry("OAI-SearchBot", "OpenAI", :search),
|
|
51
|
+
entry("Claude-SearchBot", "Anthropic", :search),
|
|
52
|
+
entry("PerplexityBot", "Perplexity", :search),
|
|
53
|
+
|
|
54
|
+
entry("ChatGPT-User", "OpenAI", :user, honors_robots: :may_ignore),
|
|
55
|
+
entry("Claude-User", "Anthropic", :user),
|
|
56
|
+
entry("Perplexity-User", "Perplexity", :user, honors_robots: :may_ignore),
|
|
57
|
+
entry("Meta-ExternalFetcher", "Meta", :user, honors_robots: :may_ignore)
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
# @return [Hash{Symbol => Array<String>}] default user-agent tokens per purpose, frozen
|
|
61
|
+
DEFAULT_USER_AGENTS = PURPOSES.to_h do |purpose|
|
|
62
|
+
[purpose, ALL.select { |crawler| crawler.purpose == purpose }.map(&:user_agent).freeze]
|
|
63
|
+
end.freeze
|
|
64
|
+
|
|
65
|
+
# @param user_agent [String] robots.txt token (case-insensitive) or a full User-Agent header
|
|
66
|
+
# @return [Crawler, nil] the table entry whose token appears in +user_agent+
|
|
67
|
+
def self.find(user_agent)
|
|
68
|
+
return nil if user_agent.nil? || user_agent.empty?
|
|
69
|
+
|
|
70
|
+
needle = user_agent.downcase
|
|
71
|
+
exact = ALL.find { |crawler| crawler.user_agent.downcase == needle }
|
|
72
|
+
return exact if exact
|
|
73
|
+
|
|
74
|
+
# Longest token first so "Claude-SearchBot" is not matched by a shorter token.
|
|
75
|
+
ALL.sort_by { |crawler| -crawler.user_agent.length }
|
|
76
|
+
.find { |crawler| needle.include?(crawler.user_agent.downcase) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SeoGeo
|
|
4
|
+
# Mount at the root so /robots.txt, /sitemap.xml and /llms.txt resolve:
|
|
5
|
+
# mount SeoGeo::Engine, at: "/"
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace SeoGeo
|
|
8
|
+
|
|
9
|
+
# The same object as SeoGeo.config, so `config.seo_geo.site.name = ...` in
|
|
10
|
+
# config/application.rb and `SeoGeo.configure` in an initializer are interchangeable.
|
|
11
|
+
config.seo_geo = SeoGeo.config
|
|
12
|
+
|
|
13
|
+
initializer "seo_geo.pages" do |app|
|
|
14
|
+
default = app.root.join("config/seo_geo/pages").to_s
|
|
15
|
+
SeoGeo.pages.paths |= [default]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# initializer "seo_geo.helpers" (ActionView/ActionController on_load hooks).
|
|
19
|
+
initializer "seo_geo.helpers" do
|
|
20
|
+
ActiveSupport.on_load(:action_view) do
|
|
21
|
+
include SeoGeo::Meta::Helper
|
|
22
|
+
include SeoGeo::CrawlerFallback::Helper
|
|
23
|
+
end
|
|
24
|
+
ActiveSupport.on_load(:action_controller_base) { include SeoGeo::Controller }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# initializer "seo_geo.mime" (Mime::Type.register "text/markdown", :md).
|
|
28
|
+
initializer "seo_geo.mime" do |app|
|
|
29
|
+
Mime::Type.register "text/markdown", :md unless Mime::Type.lookup_by_extension(:md)
|
|
30
|
+
# /<path>.md and /index.md for every route; appended, so ActionDispatch::Static (public/*.md) wins.
|
|
31
|
+
app.middleware.use SeoGeo::Markdown::PathSuffix
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# IndexNow job (only when ActiveJob is loaded) and the seo_geo_indexnow model macro.
|
|
35
|
+
initializer "seo_geo.index_now" do
|
|
36
|
+
Rails.autoloaders.main.ignore(root.join("app/jobs")) unless defined?(ActiveJob::Base)
|
|
37
|
+
ActiveSupport.on_load(:active_record) { extend SeoGeo::Model }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
config.after_initialize do
|
|
41
|
+
SeoGeo::Engine.check_configuration!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# +rake seo_geo:audit+ requests the production host names in-process. Outside production Rails
|
|
45
|
+
# HostAuthorization (config.hosts, e.g. [".localhost"]) would answer 403 for them, and config.hosts is copied
|
|
46
|
+
# when the middleware stack is built — so with SEO_GEO_AUDIT=1 (set by the rake task; set it yourself for
|
|
47
|
+
# +bin/rails runner+) the configured hosts are appended here, after the app's initializers and before the stack
|
|
48
|
+
# is built. Never in production, never without the flag, never when config.hosts is empty (allows all).
|
|
49
|
+
AUDIT_FLAG = "SEO_GEO_AUDIT"
|
|
50
|
+
|
|
51
|
+
initializer "seo_geo.audit_hosts", after: :load_config_initializers, before: :build_middleware_stack do |app|
|
|
52
|
+
SeoGeo::Engine.allow_audit_hosts!(app.config)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param app_config [Rails::Application::Configuration]
|
|
56
|
+
# @param env [Hash] process environment (the flag)
|
|
57
|
+
# @param production [Boolean]
|
|
58
|
+
# @return [Array<String>] hosts appended to +config.hosts+ (empty when nothing was changed)
|
|
59
|
+
def self.allow_audit_hosts!(app_config, env: ENV, production: Rails.env.production?, logger: Rails.logger)
|
|
60
|
+
return [] if production || env[AUDIT_FLAG] != "1"
|
|
61
|
+
return [] unless app_config.respond_to?(:hosts) && !app_config.hosts.empty?
|
|
62
|
+
|
|
63
|
+
added = SeoGeo.config.known_hosts - app_config.hosts.map(&:to_s)
|
|
64
|
+
return [] if added.empty?
|
|
65
|
+
|
|
66
|
+
app_config.hosts.concat(added)
|
|
67
|
+
message = "seo_geo audit: temporarily allowing #{added.join(", ")} in config.hosts"
|
|
68
|
+
logger&.info(message)
|
|
69
|
+
Kernel.warn(message)
|
|
70
|
+
added
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
MISSING_ORIGIN = "seo_geo: canonical_origin is not set, so every host is fail-closed (Disallow: /, noindex). " \
|
|
74
|
+
"Run bin/rails g seo_geo:install https://www.example.com or set it in config/initializers/seo_geo.rb."
|
|
75
|
+
|
|
76
|
+
PRODUCTION_TOOLING = "seo_geo: invalid configuration ignored while a generator or rake task runs in production " \
|
|
77
|
+
"(a production server boot raises):"
|
|
78
|
+
|
|
79
|
+
# Boot-time validation that cannot lock a fresh app out of its own installer or build:
|
|
80
|
+
# - production server boot: {Configuration#validate!} raises on any problem;
|
|
81
|
+
# - production generator/rake task (+assets:precompile+, +db:migrate+ in a Docker build where the origin comes
|
|
82
|
+
# from runtime ENV): warns and continues;
|
|
83
|
+
# - generators and rake tasks elsewhere: skipped (+rails g seo_geo:install+ must boot before the initializer exists);
|
|
84
|
+
# - otherwise a missing +canonical_origin+ only logs a warning; other problems still raise.
|
|
85
|
+
# @return [void]
|
|
86
|
+
def self.check_configuration!(config: SeoGeo.config, production: Rails.env.production?, tooling: tooling?,
|
|
87
|
+
logger: Rails.logger)
|
|
88
|
+
if production
|
|
89
|
+
return config.validate! unless tooling
|
|
90
|
+
|
|
91
|
+
problems = config.errors
|
|
92
|
+
unless problems.empty?
|
|
93
|
+
message = "#{PRODUCTION_TOOLING}\n- #{problems.join("\n- ")}"
|
|
94
|
+
logger&.warn(message)
|
|
95
|
+
Kernel.warn(message)
|
|
96
|
+
end
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
return if tooling
|
|
100
|
+
|
|
101
|
+
if config.canonical_origin.nil?
|
|
102
|
+
logger&.warn(MISSING_ORIGIN)
|
|
103
|
+
Kernel.warn(MISSING_ORIGIN)
|
|
104
|
+
problems = config.errors.reject { |message| message.start_with?("canonical_origin is required") }
|
|
105
|
+
raise ConfigurationError, "invalid seo_geo configuration:\n- #{problems.join("\n- ")}" unless problems.empty?
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
config.validate!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Boolean] true while +rails generate/destroy+ boots the app (their command classes are loaded only
|
|
112
|
+
# for those commands, before boot) or a rake task runs (+rails db:migrate+, +rake ...+). Merely having
|
|
113
|
+
# +rails/generators+ loaded does not count.
|
|
114
|
+
def self.tooling?
|
|
115
|
+
return true if defined?(::Rails::Command::GenerateCommand) || defined?(::Rails::Command::DestroyCommand)
|
|
116
|
+
return false unless defined?(::Rake) && ::Rake.respond_to?(:application)
|
|
117
|
+
|
|
118
|
+
!::Rake.application.top_level_tasks.empty?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# lib/tasks/*.rake is loaded by Rails::Engine itself; loading it again here ran every task twice.
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "logger"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module SeoGeo
|
|
10
|
+
# IndexNow (https://www.indexnow.org/documentation): tells Bing, Naver, Yandex, Seznam and Yep that
|
|
11
|
+
# URLs changed. One POST to the shared endpoint reaches every participant. Google does not take part.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# SeoGeo.configure do |c|
|
|
15
|
+
# c.indexnow.enabled = true
|
|
16
|
+
# c.indexnow.key = ENV["INDEXNOW_KEY"] # also served at /<key>.txt
|
|
17
|
+
# end
|
|
18
|
+
# SeoGeo::IndexNow.submit(["/articles/1", "https://www.example.com/articles/2"])
|
|
19
|
+
#
|
|
20
|
+
# Outside indexable environments (or with +indexnow.dry_run = true+) the payload is logged, not sent.
|
|
21
|
+
module IndexNow
|
|
22
|
+
# Base class for delivery failures.
|
|
23
|
+
class Error < StandardError
|
|
24
|
+
# @return [Integer, nil] HTTP status
|
|
25
|
+
attr_reader :status
|
|
26
|
+
|
|
27
|
+
def initialize(message, status: nil)
|
|
28
|
+
super(message)
|
|
29
|
+
@status = status
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 429, 5xx and network errors: the job retries with backoff.
|
|
34
|
+
class RetryableError < Error; end
|
|
35
|
+
|
|
36
|
+
# 400 bad request, 403 key not valid, 422 URLs do not belong to the host: retrying cannot help.
|
|
37
|
+
class RejectedError < Error; end
|
|
38
|
+
|
|
39
|
+
# Names other routes serve as +/<name>.txt+; never accepted as a key.
|
|
40
|
+
RESERVED_KEYS = %w[robots llms llms-full humans security ads app-ads].freeze
|
|
41
|
+
|
|
42
|
+
# Route constraint for the key file: matches only while a valid, non-reserved key is configured
|
|
43
|
+
# and equals the requested one, so /robots.txt, /llms-full.txt and other +.txt+ paths are never caught.
|
|
44
|
+
module KeyConstraint
|
|
45
|
+
# @param request [ActionDispatch::Request]
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def self.matches?(request)
|
|
48
|
+
key = SeoGeo.config.indexnow.key.to_s
|
|
49
|
+
IndexNowOptions::KEY_FORMAT.match?(key) && !RESERVED_KEYS.include?(key.downcase) &&
|
|
50
|
+
request.path_parameters[:indexnow_key] == key
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Outcome of one POST (or one dry run).
|
|
55
|
+
# @!attribute host [String]
|
|
56
|
+
# @!attribute urls [Array<String>]
|
|
57
|
+
# @!attribute status [Integer, nil] HTTP status; nil for a dry run
|
|
58
|
+
# @!attribute dry_run [Boolean]
|
|
59
|
+
Result = Data.define(:host, :urls, :status, :dry_run)
|
|
60
|
+
|
|
61
|
+
REJECTED = {
|
|
62
|
+
400 => "bad request (invalid payload format)",
|
|
63
|
+
403 => "key not valid (key file missing or its content does not match)",
|
|
64
|
+
422 => "URLs do not belong to the host, or the key does not match the protocol schema"
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# @return [Logger]
|
|
69
|
+
attr_writer :logger
|
|
70
|
+
|
|
71
|
+
# @return [Logger] Rails.logger when available
|
|
72
|
+
def logger
|
|
73
|
+
@logger || (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || Logger.new($stderr)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Normalizes, de-duplicates and groups URLs by host, then enqueues one {IndexNowJob} per batch
|
|
77
|
+
# (or delivers inline when ActiveJob is not loaded).
|
|
78
|
+
# @param urls [String, Array<String>] absolute URLs or paths; paths and alias hosts fold onto
|
|
79
|
+
# the canonical origin via +config.canonical_url+
|
|
80
|
+
# @param config [Configuration]
|
|
81
|
+
# @return [Array<Array(String, Array<String>)>] the +[host, urls]+ batches handed off; empty when disabled
|
|
82
|
+
def submit(urls, config: SeoGeo.config)
|
|
83
|
+
return [] unless config.indexnow.enabled
|
|
84
|
+
|
|
85
|
+
batches(urls, config: config).each do |host, list|
|
|
86
|
+
if job_class
|
|
87
|
+
job_class.perform_later(host, list)
|
|
88
|
+
else
|
|
89
|
+
deliver(host, list, config: config)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param urls [String, Array<String>]
|
|
95
|
+
# @param config [Configuration]
|
|
96
|
+
# @return [Array<Array(String, Array<String>)>] +[host, urls]+ pairs of at most +batch_size+ URLs
|
|
97
|
+
def batches(urls, config: SeoGeo.config)
|
|
98
|
+
grouped = normalize(urls, config).group_by { |url| URI.parse(url).host }
|
|
99
|
+
grouped.flat_map do |host, list|
|
|
100
|
+
if config.indexable_environment? && !config.host_policy(host).indexable
|
|
101
|
+
logger.info("[seo_geo] IndexNow skipped #{list.size} URL(s) on non-indexable host #{host}")
|
|
102
|
+
next []
|
|
103
|
+
end
|
|
104
|
+
list.each_slice(config.indexnow.batch_size).map { |slice| [host, slice] }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @param host [String]
|
|
109
|
+
# @param urls [Array<String>]
|
|
110
|
+
# @param config [Configuration]
|
|
111
|
+
# @return [Hash] the JSON body defined by the protocol
|
|
112
|
+
def payload(host, urls, config: SeoGeo.config)
|
|
113
|
+
key = config.indexnow.key
|
|
114
|
+
{ host: host, key: key, keyLocation: "#{config.origin_for(host)}/#{key}.txt", urlList: urls }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param config [Configuration]
|
|
118
|
+
# @return [Boolean] +indexnow.dry_run+, or true outside indexable environments when it is nil
|
|
119
|
+
def dry_run?(config: SeoGeo.config)
|
|
120
|
+
value = config.indexnow.dry_run
|
|
121
|
+
value.nil? ? !config.indexable_environment? : value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# POSTs one batch. Called by {IndexNowJob}; usable directly for synchronous delivery.
|
|
125
|
+
# @param host [String]
|
|
126
|
+
# @param urls [Array<String>]
|
|
127
|
+
# @param config [Configuration]
|
|
128
|
+
# @raise [RetryableError] on 429, 5xx or a network error
|
|
129
|
+
# @raise [RejectedError] on 400, 403, 422 or another unexpected status
|
|
130
|
+
# @return [Result]
|
|
131
|
+
def deliver(host, urls, config: SeoGeo.config)
|
|
132
|
+
body = JSON.generate(payload(host, urls, config: config))
|
|
133
|
+
if dry_run?(config: config)
|
|
134
|
+
logger.info("[seo_geo] IndexNow dry-run POST #{config.indexnow.endpoint} #{body}")
|
|
135
|
+
return Result.new(host: host, urls: urls, status: nil, dry_run: true)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
status = post(config.indexnow.endpoint, body)
|
|
139
|
+
handle(status, host, urls)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def job_class
|
|
145
|
+
defined?(ActiveJob::Base) && defined?(SeoGeo::IndexNowJob) ? SeoGeo::IndexNowJob : nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def normalize(urls, config)
|
|
149
|
+
Array(urls).flatten.compact.map(&:to_s).reject(&:empty?).filter_map do |url|
|
|
150
|
+
folded = config.canonical_url(url)
|
|
151
|
+
folded if folded.start_with?("http://", "https://")
|
|
152
|
+
rescue URI::InvalidURIError
|
|
153
|
+
logger.warn("[seo_geo] IndexNow ignored invalid URL #{url.inspect}")
|
|
154
|
+
nil
|
|
155
|
+
end.uniq
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def post(endpoint, body)
|
|
159
|
+
uri = URI.parse(endpoint)
|
|
160
|
+
request = Net::HTTP::Post.new(uri)
|
|
161
|
+
request["Content-Type"] = "application/json; charset=utf-8"
|
|
162
|
+
request.body = body
|
|
163
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
164
|
+
open_timeout: 5, read_timeout: 15) do |http|
|
|
165
|
+
http.request(request)
|
|
166
|
+
end
|
|
167
|
+
response.code.to_i
|
|
168
|
+
rescue IOError, SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError => e
|
|
169
|
+
raise RetryableError, "IndexNow request failed: #{e.class}: #{e.message}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def handle(status, host, urls)
|
|
173
|
+
case status
|
|
174
|
+
when 200, 202
|
|
175
|
+
logger.info("[seo_geo] IndexNow #{status} for #{urls.size} URL(s) on #{host}")
|
|
176
|
+
Result.new(host: host, urls: urls, status: status, dry_run: false)
|
|
177
|
+
when 429, 500..599
|
|
178
|
+
raise RetryableError.new("IndexNow #{status} for #{host} (#{status == 429 ? "too many requests" : "server error"}); will retry", status: status)
|
|
179
|
+
else
|
|
180
|
+
reason = REJECTED.fetch(status, "unexpected response")
|
|
181
|
+
logger.error("[seo_geo] IndexNow #{status} for #{host}: #{reason}; not retrying")
|
|
182
|
+
raise RejectedError.new("IndexNow #{status} for #{host}: #{reason}", status: status)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|