index_now 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 131220889a57d1c72c46f1332899034547b103c7b9594d06de1b1f06dd3937d4
4
+ data.tar.gz: a78d03b12c476c130d364ad1d2a91ff9cfcbe3b1928994570730157d7db6dffc
5
+ SHA512:
6
+ metadata.gz: 36d2dce172e838142c225200bb726dfdd72da7b0a14e11f53576583474ebf0dbe2d627c009f11dd39178ed424792cfa91c723908b193d61fd7faf2c240714b5c
7
+ data.tar.gz: 96f998799a9fdf85f2af8f5aa5228f084e8d299708892756b2b4f24e7eda6e4ca55749fca322e0e70c133f79f71c6c3b34d5baede34ee30b54367f2ecec9b196
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] — 2026-10-28
4
+
5
+ - First release: announces sitemap URLs whose `lastmod` moved to IndexNow,
6
+ records what was sent, and never raises from `IndexNow.announce_changes`.
7
+ - `index_now:announce`, `index_now:pending` and `index_now:announce_all` tasks.
8
+ - `index_now:install` generator for the key file, initializer and migration.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton Kopylov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # index_now
2
+
3
+ Tells search engines which pages of a Rails app changed, through
4
+ [IndexNow](https://www.indexnow.org), so they recrawl them without waiting.
5
+
6
+ It reads the app's own sitemap, announces every URL whose `<lastmod>` moved
7
+ since it last announced that URL, and records what it sent. Running it when
8
+ nothing changed sends nothing, so it is safe to call as often as you like.
9
+
10
+ **What it reaches:** `api.indexnow.org` shares each submission with Bing (which
11
+ also backs ChatGPT search and Copilot), Yandex, Seznam, Naver and Yep.
12
+ **Google is not an IndexNow participant**; nothing here affects Google.
13
+
14
+ ## Install
15
+
16
+ ```ruby
17
+ gem "index_now"
18
+ ```
19
+
20
+ ```sh
21
+ bin/rails generate index_now:install # or --key=<existing key>
22
+ bin/rails db:migrate
23
+ ```
24
+
25
+ The generator writes:
26
+
27
+ - `public/<key>.txt`: the key file the engines fetch to verify you own the host.
28
+ The key is not a secret; commit it.
29
+ - `config/initializers/index_now.rb`
30
+ - a migration for `index_now_announcements`, one row per URL announced.
31
+
32
+ ```ruby
33
+ IndexNow.configure do |config|
34
+ config.key = "…"
35
+ config.host = "example.com" # defaults to routes.default_url_options[:host]
36
+ config.sitemap_path = "/sitemap.xml" # the default
37
+ config.enabled = Rails.env.production? # the default
38
+ end
39
+ ```
40
+
41
+ The sitemap is requested in-process (`Rails.application.call`) as
42
+ `https://<host><sitemap_path>`, so the host must pass `config.hosts`.
43
+
44
+ ## The rule
45
+
46
+ A URL is announced when it has never been announced, or when its `<lastmod>`
47
+ is newer than the one last announced for it. A URL with no `<lastmod>` is
48
+ announced once and never again, so give every page you want re-announced a
49
+ `<lastmod>`:
50
+
51
+ - **Copy:** a date you bump by hand when the page's text changes. Never the
52
+ deploy time; that announces every page on every deploy.
53
+ - **Data:** the time the data behind the page last changed.
54
+
55
+ `<sitemapindex>` files are not supported.
56
+
57
+ ## Triggering it
58
+
59
+ ```ruby
60
+ IndexNow.announce_changes # => #<data IndexNow::Result urls=[…], error=nil>
61
+ ```
62
+
63
+ Call it after the data is committed: the sitemap is read on a separate thread
64
+ and connection. It never raises: a failure is logged, reported through `Rails.error`, and
65
+ returned as `result.error`. Nothing is recorded for a URL until the POST that
66
+ carried it succeeded, so the next call retries it.
67
+
68
+ **After a deploy** (for copy), from `.kamal/hooks/post-deploy`:
69
+
70
+ ```sh
71
+ if [ "$KAMAL_DESTINATION" = "production" ]; then
72
+ bin/kamal app exec -d production --reuse --primary "bin/rails index_now:announce" || true
73
+ fi
74
+ ```
75
+
76
+ **After the app changes data** (for data pages), from the job that wrote it:
77
+
78
+ ```ruby
79
+ def perform
80
+ Stats.rebuild!
81
+ IndexNow.announce_changes
82
+ end
83
+ ```
84
+
85
+ ## Tasks
86
+
87
+ ```sh
88
+ bin/rails index_now:pending # what the next announce would send
89
+ bin/rails index_now:announce # send it; exits 1 on failure, says so when disabled
90
+ bin/rails index_now:announce_all # send every sitemap URL again
91
+ ```
92
+
93
+ ## Rotating the key
94
+
95
+ Rename `public/<old key>.txt` to the new key, put the same key in its body and in
96
+ `config.key`, deploy, check `https://<host>/<key>.txt` answers 200, then run
97
+ `index_now:announce_all`.
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,23 @@
1
+ module IndexNow
2
+ class Announcement < ActiveRecord::Base
3
+ self.table_name = "index_now_announcements"
4
+
5
+ def self.due(entries)
6
+ announced = where(url: entries.map(&:url)).pluck(:url, :lastmod).to_h
7
+
8
+ entries.select do |entry|
9
+ next true unless announced.key?(entry.url)
10
+
11
+ previous = announced[entry.url]
12
+ entry.lastmod && (previous.nil? || entry.lastmod > previous)
13
+ end
14
+ end
15
+
16
+ def self.record!(entries)
17
+ return if entries.empty?
18
+
19
+ now = Time.current
20
+ upsert_all(entries.map { |entry| { url: entry.url, lastmod: entry.lastmod, announced_at: now } }, unique_by: :url)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,10 @@
1
+ class CreateIndexNowAnnouncements < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :index_now_announcements do |t|
4
+ t.string :url, null: false
5
+ t.datetime :lastmod
6
+ t.datetime :announced_at, null: false
7
+ end
8
+ add_index :index_now_announcements, :url, unique: true
9
+ end
10
+ end
@@ -0,0 +1,35 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+ require "securerandom"
4
+
5
+ module IndexNow
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include Rails::Generators::Migration
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+ class_option :key, type: :string, desc: "Reuse an existing IndexNow key instead of generating one"
12
+
13
+ def self.next_migration_number(dirname) = ActiveRecord::Generators::Base.next_migration_number(dirname)
14
+
15
+ def create_key_file
16
+ create_file "public/#{key}.txt", key
17
+ end
18
+
19
+ def create_initializer
20
+ template "initializer.rb.tt", "config/initializers/index_now.rb"
21
+ end
22
+
23
+ def copy_migration
24
+ migration_template "create_index_now_announcements.rb.tt", "db/migrate/create_index_now_announcements.rb"
25
+ end
26
+
27
+ private
28
+ def key
29
+ @key ||= (options[:key] || SecureRandom.hex(16)).tap do |key|
30
+ raise ArgumentError, "an IndexNow key is 8 to 128 letters, digits or dashes" unless key.match?(/\A[a-zA-Z0-9-]{8,128}\z/)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,10 @@
1
+ class CreateIndexNowAnnouncements < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :index_now_announcements do |t|
4
+ t.string :url, null: false
5
+ t.datetime :lastmod
6
+ t.datetime :announced_at, null: false
7
+ end
8
+ add_index :index_now_announcements, :url, unique: true
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ IndexNow.configure do |config|
2
+ config.key = "<%= key %>"
3
+ # config.host = "example.com"
4
+ # config.sitemap_path = "/sitemap.xml"
5
+ end
@@ -0,0 +1,56 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module IndexNow
5
+ class SubmitFailed < Error; end
6
+
7
+ module Client
8
+ ENDPOINT = URI("https://api.indexnow.org/indexnow").freeze
9
+ BATCH = 10_000
10
+
11
+ class << self
12
+ def submit!(urls)
13
+ return 0 if urls.empty?
14
+
15
+ host = host_of(urls)
16
+ urls.each_slice(BATCH) { |batch| post(host, batch) }
17
+ urls.size
18
+ end
19
+
20
+ private
21
+ def host_of(urls)
22
+ host = URI(urls.first).host
23
+ foreign = urls.reject { |url| URI(url).host == host }
24
+ raise SubmitFailed, "not on #{host}, refusing to submit: #{foreign.join(", ")}" if foreign.any?
25
+
26
+ host
27
+ end
28
+
29
+ def post(host, urls)
30
+ request = Net::HTTP::Post.new(ENDPOINT)
31
+ request["Content-Type"] = "application/json; charset=utf-8"
32
+ request.body = JSON.generate(host: host, key: key, keyLocation: key_location(host), urlList: urls)
33
+
34
+ response = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, open_timeout: 10, read_timeout: 20) do |http|
35
+ http.request(request)
36
+ end
37
+ code = response.code.to_i
38
+ raise SubmitFailed, failure_message(code, host) unless [ 200, 202 ].include?(code)
39
+ end
40
+
41
+ def key = IndexNow.config.key
42
+
43
+ def key_location(host) = "https://#{host}/#{key}.txt"
44
+
45
+ def failure_message(code, host)
46
+ case code
47
+ when 400 then "400 malformed request: the JSON body was rejected"
48
+ when 403 then "403 key not valid: #{key_location(host)} must be reachable and contain exactly the key"
49
+ when 422 then "422 the urls do not belong to #{host}, or the key does not match the schema"
50
+ when 429 then "429 too many requests: rate limited as potential spam"
51
+ else "IndexNow answered HTTP #{code}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,17 @@
1
+ module IndexNow
2
+ class Configuration
3
+ attr_accessor :key, :sitemap_path
4
+ attr_writer :host, :enabled
5
+
6
+ def initialize
7
+ @sitemap_path = "/sitemap.xml"
8
+ end
9
+
10
+ def host
11
+ @host || Rails.application.routes.default_url_options[:host] ||
12
+ raise(Error, "IndexNow has no host: set config.host or routes.default_url_options[:host]")
13
+ end
14
+
15
+ def enabled = @enabled.nil? ? Rails.env.production? : @enabled
16
+ end
17
+ end
@@ -0,0 +1,9 @@
1
+ module IndexNow
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace IndexNow
4
+
5
+ rake_tasks do
6
+ load File.expand_path("../tasks/index_now.rake", __dir__)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module IndexNow
2
+ Result = Data.define(:urls, :error, :disabled) do
3
+ def initialize(urls: [], error: nil, disabled: false) = super
4
+
5
+ def ok? = error.nil?
6
+
7
+ def disabled? = disabled
8
+ end
9
+ end
@@ -0,0 +1,55 @@
1
+ require "nokogiri"
2
+
3
+ module IndexNow
4
+ class SitemapError < Error; end
5
+
6
+ module Sitemap
7
+ Entry = Data.define(:url, :lastmod)
8
+
9
+ class << self
10
+ def entries
11
+ parse(fetch).xpath("/urlset/url").map do |url|
12
+ Entry.new(url: url.at("loc").text.strip, lastmod: url.at("lastmod")&.text&.then { |text| lastmod(text) })
13
+ end.uniq(&:url)
14
+ end
15
+
16
+ private
17
+ def url = "https://#{IndexNow.config.host}#{IndexNow.config.sitemap_path}"
18
+
19
+ # Own thread: Rails.application.call runs the executor, which would reset the caller's Current and query cache.
20
+ def fetch = Thread.new { request }.value
21
+
22
+ def request
23
+ status, _headers, body = Rails.application.call(Rack::MockRequest.env_for(url, "HTTP_HOST" => IndexNow.config.host))
24
+ content = +""
25
+ body.each { |chunk| content << chunk }
26
+ raise SitemapError, failure_message(status) unless status == 200
27
+
28
+ content
29
+ ensure
30
+ body.close if body.respond_to?(:close)
31
+ end
32
+
33
+ def failure_message(status)
34
+ hint = " (is #{IndexNow.config.host} in config.hosts?)" if status == 403
35
+ "#{status} from #{url}#{hint}"
36
+ end
37
+
38
+ def parse(xml)
39
+ document = Nokogiri::XML(xml, &:strict).remove_namespaces!
40
+ root = document.root&.name
41
+ raise SitemapError, "#{url} is a #{root || "empty document"}, not a urlset" unless root == "urlset"
42
+
43
+ document
44
+ rescue Nokogiri::XML::SyntaxError => error
45
+ raise SitemapError, "#{url} is not an XML urlset: #{error.message}"
46
+ end
47
+
48
+ def lastmod(text)
49
+ text = text.strip
50
+ text = "#{text}T00:00:00Z" if text.match?(/\A\d{4}-\d{2}-\d{2}\z/)
51
+ Time.iso8601(text.sub(/T(\d{2}:\d{2})(?=Z|[+-])/, 'T\1:00')).utc.floor(6)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,3 @@
1
+ module IndexNow
2
+ VERSION = "0.1.0"
3
+ end
data/lib/index_now.rb ADDED
@@ -0,0 +1,55 @@
1
+ require "time"
2
+ require "index_now/version"
3
+
4
+ module IndexNow
5
+ class Error < StandardError; end
6
+ end
7
+
8
+ require "index_now/configuration"
9
+ require "index_now/engine"
10
+ require "index_now/result"
11
+ require "index_now/sitemap"
12
+ require "index_now/client"
13
+
14
+ module IndexNow
15
+ class << self
16
+ def config = @config ||= Configuration.new
17
+
18
+ def configure = yield(config)
19
+
20
+ def reset_config! = @config = nil
21
+
22
+ def pending = Announcement.due(Sitemap.entries)
23
+
24
+ def announce_changes = announce { pending }
25
+
26
+ def announce_all = announce { Sitemap.entries }
27
+
28
+ private
29
+ # Never raises: a failed announcement must not fail the deploy or job that asked for it.
30
+ def announce(&entries)
31
+ return dry_run(&entries) unless config.enabled
32
+
33
+ entries = entries.call
34
+ urls = entries.map(&:url)
35
+ Client.submit!(urls)
36
+ Announcement.record!(entries)
37
+ Rails.logger.info("IndexNow announced #{urls.join(", ")}") if urls.any?
38
+ Result.new(urls: urls)
39
+ rescue StandardError => error
40
+ Rails.logger.error("IndexNow failed: #{error.class}: #{error.message}")
41
+ Rails.error.report(error, handled: true, source: "index_now")
42
+ Result.new(error: error)
43
+ end
44
+
45
+ # Staging and development often cannot read their own sitemap (basic auth, config.hosts); that is not an error there.
46
+ def dry_run
47
+ urls = yield.map(&:url)
48
+ Rails.logger.info("IndexNow disabled; would announce #{urls.join(", ")}") if urls.any?
49
+ Result.new(disabled: true)
50
+ rescue StandardError => error
51
+ Rails.logger.info("IndexNow disabled; could not read the sitemap: #{error.message}")
52
+ Result.new(disabled: true)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,30 @@
1
+ namespace :index_now do
2
+ report = lambda do |result|
3
+ abort "IndexNow failed: #{result.error.message}" unless result.ok?
4
+
5
+ if result.disabled?
6
+ puts "IndexNow is disabled in #{Rails.env}; nothing sent"
7
+ elsif result.urls.empty?
8
+ puts "nothing to announce"
9
+ else
10
+ puts "announced #{result.urls.size} url#{"s" unless result.urls.one?}"
11
+ result.urls.each { |url| puts " #{url}" }
12
+ end
13
+ end
14
+
15
+ desc "Announce sitemap urls whose lastmod moved since they were last announced"
16
+ task announce: :environment do
17
+ report.call(IndexNow.announce_changes)
18
+ end
19
+
20
+ desc "Announce every sitemap url again, e.g. after rotating the key"
21
+ task announce_all: :environment do
22
+ report.call(IndexNow.announce_all)
23
+ end
24
+
25
+ desc "List the urls the next announce would send"
26
+ task pending: :environment do
27
+ urls = IndexNow.pending.map(&:url)
28
+ puts urls.empty? ? "nothing to announce" : urls
29
+ end
30
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: index_now
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Anton Kopylov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: nokogiri
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.16'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.16'
40
+ description: Reads the app's own sitemap, announces every URL whose lastmod moved
41
+ since it was last announced to api.indexnow.org, and records what it sent so nothing
42
+ is announced twice.
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - CHANGELOG.md
48
+ - LICENSE.txt
49
+ - README.md
50
+ - app/models/index_now/announcement.rb
51
+ - db/migrate/20261028000000_create_index_now_announcements.rb
52
+ - lib/generators/index_now/install/install_generator.rb
53
+ - lib/generators/index_now/install/templates/create_index_now_announcements.rb.tt
54
+ - lib/generators/index_now/install/templates/initializer.rb.tt
55
+ - lib/index_now.rb
56
+ - lib/index_now/client.rb
57
+ - lib/index_now/configuration.rb
58
+ - lib/index_now/engine.rb
59
+ - lib/index_now/result.rb
60
+ - lib/index_now/sitemap.rb
61
+ - lib/index_now/version.rb
62
+ - lib/tasks/index_now.rake
63
+ homepage: https://github.com/tonic20/index_now
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ homepage_uri: https://github.com/tonic20/index_now
68
+ source_code_uri: https://github.com/tonic20/index_now
69
+ changelog_uri: https://github.com/tonic20/index_now/blob/main/CHANGELOG.md
70
+ rdoc_options: []
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '3.3'
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ requirements: []
84
+ rubygems_version: 3.7.2
85
+ specification_version: 4
86
+ summary: Announce a Rails app's changed sitemap URLs to IndexNow
87
+ test_files: []