seo_geo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +244 -0
  3. data/CHANGELOG.md +40 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +428 -0
  6. data/app/controllers/seo_geo/index_now_controller.rb +13 -0
  7. data/app/controllers/seo_geo/llms_controller.rb +29 -0
  8. data/app/controllers/seo_geo/og_images_controller.rb +67 -0
  9. data/app/controllers/seo_geo/robots_controller.rb +14 -0
  10. data/app/controllers/seo_geo/sitemaps_controller.rb +16 -0
  11. data/app/jobs/seo_geo/index_now_job.rb +21 -0
  12. data/app/views/seo_geo/fallback/_body.html.erb +21 -0
  13. data/app/views/seo_geo/fallback/_faq.html.erb +12 -0
  14. data/config/routes.rb +17 -0
  15. data/lib/generators/seo_geo/install/install_generator.rb +138 -0
  16. data/lib/generators/seo_geo/install/templates/home.yml.tt +14 -0
  17. data/lib/generators/seo_geo/install/templates/initializer.rb.tt +52 -0
  18. data/lib/seo_geo/audit/checks.rb +107 -0
  19. data/lib/seo_geo/audit/html_document.rb +132 -0
  20. data/lib/seo_geo/audit/report.rb +85 -0
  21. data/lib/seo_geo/audit/runner.rb +327 -0
  22. data/lib/seo_geo/audit/static_scan.rb +60 -0
  23. data/lib/seo_geo/audit.rb +14 -0
  24. data/lib/seo_geo/bot_verifier.rb +278 -0
  25. data/lib/seo_geo/configuration.rb +832 -0
  26. data/lib/seo_geo/controller.rb +72 -0
  27. data/lib/seo_geo/crawler_fallback.rb +45 -0
  28. data/lib/seo_geo/crawlers.rb +79 -0
  29. data/lib/seo_geo/engine.rb +123 -0
  30. data/lib/seo_geo/index_now.rb +187 -0
  31. data/lib/seo_geo/json_ld.rb +259 -0
  32. data/lib/seo_geo/llms_txt.rb +183 -0
  33. data/lib/seo_geo/markdown/document.rb +163 -0
  34. data/lib/seo_geo/markdown/negotiation.rb +49 -0
  35. data/lib/seo_geo/markdown/path_suffix.rb +72 -0
  36. data/lib/seo_geo/markdownable.rb +141 -0
  37. data/lib/seo_geo/meta.rb +382 -0
  38. data/lib/seo_geo/model.rb +95 -0
  39. data/lib/seo_geo/og_image/card.html.erb +22 -0
  40. data/lib/seo_geo/og_image/ferrum_backend.rb +72 -0
  41. data/lib/seo_geo/og_image/renderer.rb +163 -0
  42. data/lib/seo_geo/page.rb +201 -0
  43. data/lib/seo_geo/path_match.rb +36 -0
  44. data/lib/seo_geo/publication.rb +68 -0
  45. data/lib/seo_geo/robots/evaluator.rb +85 -0
  46. data/lib/seo_geo/robots.rb +105 -0
  47. data/lib/seo_geo/robots_directives.rb +170 -0
  48. data/lib/seo_geo/robots_path.rb +125 -0
  49. data/lib/seo_geo/rspec.rb +197 -0
  50. data/lib/seo_geo/safe_regexp.rb +50 -0
  51. data/lib/seo_geo/sitemap/registry.rb +239 -0
  52. data/lib/seo_geo/sitemap/xml.rb +138 -0
  53. data/lib/seo_geo/testing/environment.rb +31 -0
  54. data/lib/seo_geo/testing.rb +111 -0
  55. data/lib/seo_geo/undeclared_host.rb +40 -0
  56. data/lib/seo_geo/version.rb +5 -0
  57. data/lib/seo_geo.rb +80 -0
  58. data/lib/tasks/seo_geo.rake +23 -0
  59. data/llms.txt +27 -0
  60. metadata +224 -0
@@ -0,0 +1,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