indexmap 0.8.2 → 0.9.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 +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +287 -35
- data/UPGRADE.md +361 -0
- data/docs/architecture.md +55 -0
- data/docs/deployment.md +223 -0
- data/docs/domain-terms.md +27 -0
- data/docs/dynamic-sitemaps.md +302 -0
- data/docs/search-engine-ping.md +368 -0
- data/docs/storage.md +94 -0
- data/lib/generators/indexmap/install_generator.rb +1 -1
- data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
- data/lib/indexmap/artifact.rb +9 -0
- data/lib/indexmap/configuration.rb +12 -1
- data/lib/indexmap/creator.rb +45 -42
- data/lib/indexmap/index_now_configuration.rb +3 -1
- data/lib/indexmap/output.rb +19 -5
- data/lib/indexmap/parser.rb +112 -52
- data/lib/indexmap/pinger/base.rb +6 -5
- data/lib/indexmap/pinger/google.rb +12 -8
- data/lib/indexmap/pinger/index_now.rb +97 -52
- data/lib/indexmap/publication.rb +89 -0
- data/lib/indexmap/robots.rb +67 -0
- data/lib/indexmap/robots_controller.rb +13 -0
- data/lib/indexmap/sitemaps_controller.rb +57 -0
- data/lib/indexmap/storage/active_storage.rb +132 -1
- data/lib/indexmap/storage/filesystem.rb +182 -11
- data/lib/indexmap/storage/memory.rb +1 -1
- data/lib/indexmap/task_runner.rb +21 -28
- data/lib/indexmap/validator/duplicates.rb +83 -0
- data/lib/indexmap/validator.rb +171 -59
- data/lib/indexmap/version.rb +1 -1
- data/lib/indexmap/writer.rb +89 -34
- data/lib/indexmap.rb +14 -1
- data/lib/tasks/indexmap_tasks.rake +21 -2
- data/test/indexmap/correctness_test.rb +153 -0
- data/test/indexmap/install_generator_test.rb +36 -4
- data/test/indexmap/parser_test.rb +77 -0
- data/test/indexmap/pinger/google_test.rb +22 -0
- data/test/indexmap/pinger/index_now_test.rb +168 -0
- data/test/indexmap/publication_test.rb +280 -0
- data/test/indexmap/robots_test.rb +73 -0
- data/test/indexmap/sitemaps_controller_test.rb +50 -0
- data/test/indexmap/storage_test.rb +27 -0
- data/test/indexmap/task_runner_test.rb +24 -0
- data/test/indexmap/validator_test.rb +14 -0
- data/test/indexmap/writer_test.rb +42 -0
- data/test/integration/queue_checks.rb +244 -0
- data/test/release_task_test.rb +153 -0
- metadata +22 -5
- data/lib/indexmap/storage/file.rb +0 -11
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Database-backed sitemaps
|
|
2
|
+
|
|
3
|
+
> This guide describes unreleased main. For RubyGems 0.8.2, use the
|
|
4
|
+
> [tagged documentation](https://github.com/ethos-link/indexmap/tree/v0.8.2).
|
|
5
|
+
> See [release status](../README.md#release-status) and [UPGRADE.md](../UPGRADE.md).
|
|
6
|
+
|
|
7
|
+
Indexmap generates sitemap artifacts from app-owned sources. Generation can run
|
|
8
|
+
in a job or rake task; HTTP requests serve published artifacts and never query
|
|
9
|
+
source records. No model concern, queue backend, schedule, or content eligibility
|
|
10
|
+
policy is installed by the gem.
|
|
11
|
+
|
|
12
|
+
## Configure generation
|
|
13
|
+
|
|
14
|
+
Run `bin/rails generate indexmap:install`, then edit the generated configure block:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
config.base_url = -> { ENV.fetch("PUBLIC_ORIGIN") }
|
|
18
|
+
config.storage = Indexmap::Storage::Filesystem.new(
|
|
19
|
+
path: Rails.root.join("storage/sitemaps"), public_url: config.base_url
|
|
20
|
+
)
|
|
21
|
+
config.atomic = true
|
|
22
|
+
config.max_urls = 45_000
|
|
23
|
+
config.sections = -> { Sitemap.sections }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`Sitemap` is the application's manifest. It should return fresh sections whose
|
|
27
|
+
entries are arrays or enumerables. Use native `find_each`/`in_batches`, selected
|
|
28
|
+
columns, and SQL aggregates to select eligible records. Calculate canonical URLs
|
|
29
|
+
with app-owned routing rules. Do not serialize a relation or URL inventory into
|
|
30
|
+
a job, reuse an exhausted source across refreshes, or use refresh time as the
|
|
31
|
+
content's `lastmod`. Time-dependent eligibility needs recurring refreshes even
|
|
32
|
+
when no record is updated. A batch scan is not a point-in-time database snapshot.
|
|
33
|
+
|
|
34
|
+
Generation stages XML on local temporary disk, validates the complete graph, then
|
|
35
|
+
publishes. Entry sources are consumed once. Index mode splits a section before
|
|
36
|
+
`max_urls` (default 50,000) or `max_bytes` (default 52,428,800) is exceeded. Limits
|
|
37
|
+
must be positive integers within the protocol limits. Size includes escaped UTF-8
|
|
38
|
+
XML and document overhead. Locations must be shorter than 2,048 characters.
|
|
39
|
+
A single oversized entry fails; a single-file output
|
|
40
|
+
that needs another partition fails with instructions to use index mode. Empty
|
|
41
|
+
sections are omitted; an empty root fails without replacing the published root.
|
|
42
|
+
The index itself must fit the protocol entry limit and configured byte limit.
|
|
43
|
+
|
|
44
|
+
In legacy mode, the first partition keeps the section filename; later partitions
|
|
45
|
+
append `-002.xml`, `-003.xml`, etc. Atomic mode chooses immutable child filenames
|
|
46
|
+
instead; the configured root filename remains stable. Do not infer eligibility,
|
|
47
|
+
content changes, or section identity from generated child filenames.
|
|
48
|
+
|
|
49
|
+
For filesystem and Active Storage destinations, generation and validation retain
|
|
50
|
+
at most a partition plus bounded parsing/sort buffers in memory, along with
|
|
51
|
+
metadata proportional to the number of sitemap files. Duplicate checking uses
|
|
52
|
+
10,000-URL sorted runs and a disk merge, including duplicates across partitions.
|
|
53
|
+
Temporary disk use scales with the XML and URL inventory; provision enough local
|
|
54
|
+
disk and monitor it. Validation and IndexNow planning use streaming reads and
|
|
55
|
+
private temporary spools. Submission summaries retain per-batch outcomes and
|
|
56
|
+
failures, so that metadata grows with request count. `Validator#each_entry` yields
|
|
57
|
+
only after the entire inventory validates. `Validator#validate!` collects entries by default for Ruby
|
|
58
|
+
callers; use `collect: false` when the list is not needed.
|
|
59
|
+
|
|
60
|
+
The low-level `Writer#write`, `Parser#entries`, explicit `to_a` calls, memory
|
|
61
|
+
storage, and app-owned arrays can still retain the whole inventory. Use
|
|
62
|
+
`Writer#each_file` or the normal generation operation for incremental output.
|
|
63
|
+
Custom adapters without `open`/metadata or `write_io` fall back to whole-file
|
|
64
|
+
reads/writes; each file is still bounded by the configured limits. There is no
|
|
65
|
+
full-document formatting pass during generation. Task reports use counts recorded
|
|
66
|
+
while writing rather than downloading published files again. Serialization may
|
|
67
|
+
differ from older Nokogiri-reformatted output while retaining XML meaning.
|
|
68
|
+
|
|
69
|
+
## Atomic publication and storage
|
|
70
|
+
|
|
71
|
+
Atomic mode is opt-in and requires `revision(root)` and
|
|
72
|
+
`publish(source, files:, root:, expected:)` capabilities. An unsupported adapter
|
|
73
|
+
fails before sources are evaluated. See the [complete storage contract](storage.md)
|
|
74
|
+
before implementing a custom adapter. Existing six-method adapters keep legacy
|
|
75
|
+
sequential publication unless explicitly extended. Named outputs inherit atomic
|
|
76
|
+
mode and limits and can override them independently.
|
|
77
|
+
|
|
78
|
+
Both built-in persistent adapters upload immutable children before activating the
|
|
79
|
+
stable root. A root revision captured before generation must still match at
|
|
80
|
+
activation. Otherwise `Indexmap::SupersededPublication` is raised and that build
|
|
81
|
+
cannot overwrite a root published by another writer. Retry by rebuilding current
|
|
82
|
+
eligible data. Queue concurrency reduces wasted work but does not replace this
|
|
83
|
+
check, including for direct rake invocations.
|
|
84
|
+
|
|
85
|
+
Filesystem publication copies into private destination staging without holding
|
|
86
|
+
the per-root lock, including the previous root archive. Short locked sections
|
|
87
|
+
establish a staging lease and activate complete files with same-filesystem
|
|
88
|
+
renames; retirement timestamps are set at activation. Pruning skips live build
|
|
89
|
+
leases and reclaims abandoned staging after the grace period. Exclude hidden
|
|
90
|
+
`.indexmap-builds/` paths from HTTP serving and provision destination staging
|
|
91
|
+
space. The destination must provide cross-process `flock` and atomic rename
|
|
92
|
+
semantics; arbitrary NFS mounts or object-store filesystem gateways may not.
|
|
93
|
+
Storage keys cannot escape through traversal or existing symlinks. Published
|
|
94
|
+
immutable child keys cannot be overwritten through built-in write APIs.
|
|
95
|
+
|
|
96
|
+
For Active Storage, keep the host-owned artifact model with `has_one_attached`
|
|
97
|
+
and a database unique index on its filename column. Install Active Storage through
|
|
98
|
+
Rails' installer and generate any host migration with Rails; inspect and remove
|
|
99
|
+
existing duplicate rows before adding that index. Indexmap does not invent host
|
|
100
|
+
migrations or modify attachment configuration. Example generator command:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
bin/rails generate migration AddUniqueIndexToSitemapArtifactsFilename
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Edit the generated migration to add the unique index after checking existing
|
|
107
|
+
schema and data; do not add a duplicate index if one already exists. Atomic mode
|
|
108
|
+
checks that an unconditional single-column unique index exists. Root blobs carry
|
|
109
|
+
the generation identifier in native Active Storage metadata and are uploaded
|
|
110
|
+
before a short host-record lock attaches them. Expected revisions are checked
|
|
111
|
+
before uploads too; failed or losing builds immediately clean up the child
|
|
112
|
+
records and blobs they created. A race can still waste an in-flight upload, but
|
|
113
|
+
normal failure cleanup does not wait for pruning. Reading the active generation
|
|
114
|
+
requires no object-store download under that lock. Generation and uploads run
|
|
115
|
+
outside that database transaction. SQLite lock contention can raise a
|
|
116
|
+
transient database error; preserve the host's explicit retry policy.
|
|
117
|
+
|
|
118
|
+
The adapter archives the previous atomic sitemap index before activation. Archive ownership
|
|
119
|
+
proves that a retained child's generation was published; uploaded orphans are
|
|
120
|
+
excluded from shared HTTP delivery. Old children remain available for cached
|
|
121
|
+
roots. Active Storage archive age follows the artifact record, not the older
|
|
122
|
+
blob's creation date. The model's normal attachment lifecycle handles purging
|
|
123
|
+
when expired artifact records are destroyed. Ensure its purge jobs have workers;
|
|
124
|
+
use the host's existing unattached-blob cleanup for process crashes. Cleanup
|
|
125
|
+
failures raise `Indexmap::PublicationCleanupError` with failed artifact/key
|
|
126
|
+
diagnostics and the original publication exception as its cause. Preserve those
|
|
127
|
+
private diagnostics: if object deletion fails after a blob row is destroyed,
|
|
128
|
+
cleanup by object key may be necessary. Host callback errors after a committed
|
|
129
|
+
activation do not delete that published generation.
|
|
130
|
+
|
|
131
|
+
Keep the destination shared by workers and every serving instance. A container's
|
|
132
|
+
`public/` directory is not automatically shared or persistent. In legacy mode,
|
|
133
|
+
validation failure preserves old artifacts, but interrupted sequential writes can
|
|
134
|
+
still leave mixed generations. Atomic mode solves that collection-level problem.
|
|
135
|
+
|
|
136
|
+
## Jobs and schedules
|
|
137
|
+
|
|
138
|
+
Generate the job in the host application, then edit it:
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
bin/rails generate job SitemapRefresh
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
require "indexmap"
|
|
146
|
+
|
|
147
|
+
class SitemapRefreshJob < ApplicationJob
|
|
148
|
+
retry_on Indexmap::SupersededPublication, wait: :polynomially_longer, attempts: 3
|
|
149
|
+
|
|
150
|
+
def perform(_version = nil)
|
|
151
|
+
Indexmap::HostConfiguration.apply
|
|
152
|
+
Indexmap::TaskRunner.new.create
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Load the host configuration file first if it lives outside an initializer. The
|
|
158
|
+
explicit require above makes the exception class available when a job is
|
|
159
|
+
eager-loaded; it does not evaluate section sources. The host's `ApplicationJob` context,
|
|
160
|
+
authorization, and transaction hooks remain authoritative. Pass scalar output or
|
|
161
|
+
release identifiers and enqueue after source transactions commit and database
|
|
162
|
+
preparation completes. A separate queue database is not transactionally coupled
|
|
163
|
+
to application data.
|
|
164
|
+
|
|
165
|
+
The same body works with GoodJob and Solid Queue. Keep the adapter-specific
|
|
166
|
+
concurrency declarations and recurring schedules in the host; see the
|
|
167
|
+
[GoodJob and Solid Queue examples](../README.md#background-generation). Use a
|
|
168
|
+
shared execution key/label across releases for the same destination. Solid Queue
|
|
169
|
+
uses a shared concurrency group too if multiple job classes write that destination.
|
|
170
|
+
Prefer blocking/rescheduling to discarding a newer refresh. Choose a concurrency
|
|
171
|
+
duration above measured runtime and remember it can expire. Direct `perform_now`
|
|
172
|
+
and rake calls bypass queue coordination but still use atomic activation checks.
|
|
173
|
+
|
|
174
|
+
Use one enqueue owner per trigger. Avoid duplicate startup/release scheduling,
|
|
175
|
+
per-country fan-out, and continuations until actual runtime justifies them. Retry
|
|
176
|
+
identified transient storage/database errors deliberately; do not repeatedly retry
|
|
177
|
+
invalid configuration or content. `after_create` callbacks run after publication
|
|
178
|
+
and must be safe to repeat. A callback failure does not roll publication back.
|
|
179
|
+
|
|
180
|
+
`TaskRunner#create` runs default-output callbacks and key-file handling.
|
|
181
|
+
`Indexmap.create(:name)` generates a named output without those callbacks. Keep
|
|
182
|
+
that distinction when designing independent refresh jobs. Generation does not
|
|
183
|
+
submit to IndexNow; keep sending as a separate operation so refresh retries do
|
|
184
|
+
not resend successful batches.
|
|
185
|
+
|
|
186
|
+
## Serve published XML
|
|
187
|
+
|
|
188
|
+
Require the controller and explicitly mount the root and generated child pattern.
|
|
189
|
+
This example assumes root-level storage keys and `index_filename = "sitemap.xml"`:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
require "indexmap/sitemaps_controller"
|
|
193
|
+
Indexmap::HostConfiguration.apply
|
|
194
|
+
|
|
195
|
+
Rails.application.routes.draw do
|
|
196
|
+
get "/sitemap.xml", to: Indexmap::SitemapsController.action(:show),
|
|
197
|
+
defaults: {filename: "sitemap.xml"}, format: false
|
|
198
|
+
get "/:filename", to: Indexmap::SitemapsController.action(:show),
|
|
199
|
+
constraints: {filename: /sitemap-[a-f0-9]{16}-[a-f0-9]{32}-[1-9]\d*-[1-9]\d*\.xml/},
|
|
200
|
+
format: false
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Mount named roots explicitly too. For nested keys, set the full root key in the
|
|
205
|
+
route defaults and a fixed `prefix` default for child routes. For example a
|
|
206
|
+
`/sitemaps/:filename` child route can use `defaults: {prefix: "sitemaps"}`. Only
|
|
207
|
+
path parameters are used, so query strings cannot supply a storage prefix. Dots
|
|
208
|
+
must be accepted by the filename constraint; disabling format parsing alone does
|
|
209
|
+
not make Rails dynamic segments accept dots. Do not mount a broad catchall route.
|
|
210
|
+
|
|
211
|
+
The controller allows configured roots and existing children from current or
|
|
212
|
+
archived atomic publications. It never resolves section providers. Other files,
|
|
213
|
+
archive indexes, and unpublished orphan children are excluded. Delivery requires
|
|
214
|
+
storage `metadata` and `open` capabilities, implemented by both persistent
|
|
215
|
+
adapters. The endpoint uses `application/xml`, streams bodies, and supports HEAD,
|
|
216
|
+
ETag, Last-Modified, and 304. Roots cache for ten minutes with one hour of stale
|
|
217
|
+
revalidation; immutable children cache for a year. Generation authorization uses
|
|
218
|
+
the host's Rails cache keyed by the current root's metadata. Authentication,
|
|
219
|
+
visitor tracking, and browser gates from host controllers are not inherited.
|
|
220
|
+
|
|
221
|
+
When switching from legacy child names, keep the old allowlisted child routes
|
|
222
|
+
or static files through the cached-root/rollback grace period. Atomic mode does
|
|
223
|
+
not rename or prune those legacy files automatically, and the new shared
|
|
224
|
+
controller does not authorize arbitrary legacy children. Add the generated child
|
|
225
|
+
route first, enable atomic publication, and retire legacy serving only after the
|
|
226
|
+
transition period. Then remove the old child artifacts deliberately.
|
|
227
|
+
|
|
228
|
+
Remove static files that would shadow these routes, or deliberately keep static
|
|
229
|
+
serving and configure its root/child cache headers appropriately. Robots is a
|
|
230
|
+
separate opt-in endpoint; see [dynamic robots](../README.md#dynamic-robots).
|
|
231
|
+
Public origins must come from configuration, not request headers. Storage public
|
|
232
|
+
URLs must be the URLs that the app actually serves, not private object-store URLs.
|
|
233
|
+
|
|
234
|
+
## Retention and operations
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
bin/rails indexmap:sitemap:create
|
|
238
|
+
bin/rails indexmap:sitemap:validate
|
|
239
|
+
RETENTION_HOURS=48 bin/rails indexmap:sitemap:prune
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Schedule pruning separately from generation. The default grace period is 48 hours;
|
|
243
|
+
values below one hour are rejected. Choose a period longer than the oldest root
|
|
244
|
+
that your caches can still serve, including stale revalidation and deployment
|
|
245
|
+
rollbacks. Retention is an operational policy, not a promise about arbitrary
|
|
246
|
+
crawler caches. Pruning is locked against activation, preserves the current
|
|
247
|
+
publication, removes expired archives and unreferenced owned children, and leaves
|
|
248
|
+
other outputs and unrelated files alone. Abandoned destination staging and
|
|
249
|
+
crash-left orphans age out too. Live filesystem staging remains protected by its
|
|
250
|
+
build lock. Active Storage activation rechecks child existence, so pruning an
|
|
251
|
+
old in-flight build forces a rebuild instead of activating an incomplete root.
|
|
252
|
+
Active Storage pruning destroys owned artifact records and delegates blob cleanup
|
|
253
|
+
to normal attachment jobs, avoiding object-store deletes under the root lock.
|
|
254
|
+
|
|
255
|
+
Do not use an old app callback that deletes everything outside configured section
|
|
256
|
+
filenames: atomic child names deliberately differ. Replace it with the gem's
|
|
257
|
+
prune task when adopting atomic mode. The format task refuses to run when an
|
|
258
|
+
atomic output is configured. Regenerate instead of mutating immutable XML.
|
|
259
|
+
|
|
260
|
+
For IndexNow selection, exact attempt exports, timeouts, and bounded retries, see
|
|
261
|
+
[search-engine submission](search-engine-ping.md#ping-indexnow). A missing timestamp
|
|
262
|
+
is not evidence of a content change, and removal from a sitemap is not evidence
|
|
263
|
+
of deletion. Durable history and automatic inventory diffs are not implemented.
|
|
264
|
+
|
|
265
|
+
## Host adoption
|
|
266
|
+
|
|
267
|
+
Release and consume the gem before removing host implementations. Local path
|
|
268
|
+
overrides are for verification only and must not be committed to applications.
|
|
269
|
+
|
|
270
|
+
1. Keep eligibility scopes, canonical URLs, content dates, and scheduling in the
|
|
271
|
+
host. Static manifests can stay database-free; database sources should return
|
|
272
|
+
fresh lazy enumerables.
|
|
273
|
+
2. Choose storage shared by refresh workers and serving instances. Verify the
|
|
274
|
+
artifact filename index when using atomic Active Storage.
|
|
275
|
+
3. Add explicit root and generated-child routes, or configure static/CDN delivery.
|
|
276
|
+
Preserve legacy child delivery through the cached-root transition.
|
|
277
|
+
4. Replace filename-based cleanup callbacks with output-scoped pruning. Preserve
|
|
278
|
+
the host's robots exclusions and production/preview indexing policy.
|
|
279
|
+
5. Verify the lifecycle in development, then staging, before enabling public
|
|
280
|
+
indexing and search-engine submission. See the [deployment guide](deployment.md).
|
|
281
|
+
|
|
282
|
+
Validate actual public root/child URLs, headers, key verification files, workers,
|
|
283
|
+
and queue failure reporting in each host. Gem integration tests exercise real
|
|
284
|
+
GoodJob, Solid Queue, and Active Storage against PostgreSQL; they do not prove a
|
|
285
|
+
particular application's deployment wiring.
|
|
286
|
+
|
|
287
|
+
## Integration checks from a source checkout
|
|
288
|
+
|
|
289
|
+
The integration harness creates a temporary Rails app and runs framework/gem
|
|
290
|
+
installers and generators. It drops and recreates the supplied `indexmap_test`
|
|
291
|
+
database. Never point it at an application database.
|
|
292
|
+
|
|
293
|
+
```sh
|
|
294
|
+
BUNDLE_GEMFILE=gemfiles/integration.gemfile bundle install
|
|
295
|
+
INDEXMAP_DATABASE_URL=postgresql://postgres@localhost/indexmap_test \
|
|
296
|
+
BUNDLE_GEMFILE=gemfiles/integration.gemfile bundle exec ruby script/integration
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The harness checks the real GoodJob adapter, a running Solid Queue worker,
|
|
300
|
+
validation failure visibility, shared filesystem output, Active Storage activation,
|
|
301
|
+
and archive retention. CI provides a disposable PostgreSQL service. No external
|
|
302
|
+
search-engine requests are made.
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# Search Engine Ping
|
|
2
|
+
|
|
3
|
+
> This guide describes unreleased main. For RubyGems 0.8.2, use the
|
|
4
|
+
> [tagged documentation](https://github.com/ethos-link/indexmap/tree/v0.8.2).
|
|
5
|
+
> See [release status](../README.md#release-status) and [UPGRADE.md](../UPGRADE.md).
|
|
6
|
+
|
|
7
|
+
`indexmap` can submit generated sitemap data to Google Search Console and
|
|
8
|
+
IndexNow. These integrations are optional: sitemap generation, formatting, and
|
|
9
|
+
validation work without any search-engine credentials.
|
|
10
|
+
|
|
11
|
+
Available rake tasks:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bin/rails indexmap:sitemap:validate
|
|
15
|
+
bin/rails indexmap:google:ping
|
|
16
|
+
bin/rails indexmap:index_now:ping
|
|
17
|
+
bin/rails indexmap:index_now:write_key
|
|
18
|
+
bin/rails indexmap:ping
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`indexmap:ping` runs both IndexNow and Google submission. Run
|
|
22
|
+
`indexmap:sitemap:create` before pinging so the configured storage contains the
|
|
23
|
+
current sitemap files.
|
|
24
|
+
|
|
25
|
+
The configured `index_filename` is the authoritative sitemap entrypoint.
|
|
26
|
+
Google submits that sitemap URL once. IndexNow recursively reads its referenced
|
|
27
|
+
child sitemaps and submits their page URLs. Other sitemap-shaped files left in
|
|
28
|
+
storage are ignored unless the authoritative sitemap references them.
|
|
29
|
+
|
|
30
|
+
## Google Search Console
|
|
31
|
+
|
|
32
|
+
Google submission uses the Search Console Sitemaps API. It submits sitemap URLs
|
|
33
|
+
such as `https://example.com/sitemap.xml` to a verified Search Console
|
|
34
|
+
property. It does not submit arbitrary page URLs to Google for immediate
|
|
35
|
+
indexing.
|
|
36
|
+
|
|
37
|
+
Current Google API contract:
|
|
38
|
+
|
|
39
|
+
- API: Search Console Sitemaps API
|
|
40
|
+
- Method: `PUT https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}`
|
|
41
|
+
- OAuth scope: `https://www.googleapis.com/auth/webmasters`
|
|
42
|
+
- Request body: empty
|
|
43
|
+
- Success response body: empty
|
|
44
|
+
|
|
45
|
+
The Google Indexing API is a separate page-level API at
|
|
46
|
+
`POST https://indexing.googleapis.com/v3/urlNotifications:publish` with the
|
|
47
|
+
`https://www.googleapis.com/auth/indexing` scope. Google documents that API for
|
|
48
|
+
JobPosting pages and livestreaming events with `BroadcastEvent` structured data,
|
|
49
|
+
not for general sitemap submission. `indexmap` therefore uses the Search Console
|
|
50
|
+
Sitemaps API for Google.
|
|
51
|
+
|
|
52
|
+
### Create The Google Credentials
|
|
53
|
+
|
|
54
|
+
1. Open or create a Google Cloud project for the site.
|
|
55
|
+
2. Enable the Google Search Console API for that project.
|
|
56
|
+
3. Create a service account for sitemap submission.
|
|
57
|
+
4. Create a JSON key for that service account.
|
|
58
|
+
5. Store the downloaded JSON as a secret in the host app. Do not commit it.
|
|
59
|
+
6. Copy the JSON key's `client_email`.
|
|
60
|
+
7. Open the Search Console property for the site.
|
|
61
|
+
8. Go to `Settings` > `Users and permissions`.
|
|
62
|
+
9. Add the service account `client_email` as a user with permission to submit
|
|
63
|
+
sitemaps. `Full` user access is sufficient for sitemap submission; `Owner`
|
|
64
|
+
also works but grants broader access.
|
|
65
|
+
|
|
66
|
+
The Search Console property must match what `indexmap` submits. Domain
|
|
67
|
+
properties use `sc-domain:example.com`; URL-prefix properties use the exact URL
|
|
68
|
+
prefix shown in Search Console, such as `https://www.example.com/`.
|
|
69
|
+
|
|
70
|
+
### Configure Indexmap
|
|
71
|
+
|
|
72
|
+
`config.google.credentials` must return the JSON key contents, not a path to a
|
|
73
|
+
file.
|
|
74
|
+
|
|
75
|
+
Rails credentials example:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
google:
|
|
79
|
+
sitemap: |
|
|
80
|
+
{
|
|
81
|
+
"type": "service_account",
|
|
82
|
+
"project_id": "example-project",
|
|
83
|
+
"private_key_id": "...",
|
|
84
|
+
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
|
|
85
|
+
"client_email": "indexmap-sitemap@example-project.iam.gserviceaccount.com",
|
|
86
|
+
"client_id": "...",
|
|
87
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
88
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
89
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
90
|
+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/indexmap-sitemap%40example-project.iam.gserviceaccount.com"
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Initializer example:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
Indexmap.configure do |config|
|
|
98
|
+
config.google.credentials = -> { Rails.application.credentials.dig(:google, :sitemap) }
|
|
99
|
+
config.google.property = -> { "sc-domain:example.com" }
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Environment variable example:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
Indexmap.configure do |config|
|
|
107
|
+
config.google.credentials = -> { ENV["GOOGLE_SITEMAP_JSON"] }
|
|
108
|
+
config.google.property = -> { ENV.fetch("GOOGLE_SEARCH_CONSOLE_PROPERTY", "sc-domain:example.com") }
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
If `config.google.property` is not set, `indexmap` defaults to
|
|
113
|
+
`sc-domain:<host>` from `config.base_url`.
|
|
114
|
+
|
|
115
|
+
Run:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bin/rails indexmap:sitemap:create
|
|
119
|
+
bin/rails indexmap:sitemap:validate
|
|
120
|
+
bin/rails indexmap:google:ping
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A successful ping prints how many sitemap files and URLs were submitted. If the
|
|
124
|
+
task says Search Console does not have access, compare the configured property
|
|
125
|
+
identifier and the service account `client_email` against the Search Console
|
|
126
|
+
property's `Users and permissions` page.
|
|
127
|
+
|
|
128
|
+
## IndexNow
|
|
129
|
+
|
|
130
|
+
IndexNow submission sends changed URL lists to the IndexNow endpoint. It is a
|
|
131
|
+
separate protocol from Google Search Console submission.
|
|
132
|
+
|
|
133
|
+
IndexNow requires a key that proves control of the submitted host. `indexmap`
|
|
134
|
+
uses a strict key format: 32 lowercase hexadecimal characters. The verification
|
|
135
|
+
file must be named `<key>.txt` unless `config.index_now.key_filename` overrides
|
|
136
|
+
the filename, and its contents must be exactly the key.
|
|
137
|
+
|
|
138
|
+
### File-Based Key Provisioning
|
|
139
|
+
|
|
140
|
+
The simplest flow is to let `indexmap` generate or reuse the key file:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
bin/rails indexmap:index_now:write_key
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
That task:
|
|
147
|
+
|
|
148
|
+
- reuses an existing valid `<key>.txt` file from the configured storage
|
|
149
|
+
- otherwise generates a new 32-character hexadecimal key
|
|
150
|
+
- writes `<key>.txt` with the key as the file contents
|
|
151
|
+
- makes that key available to `indexmap:index_now:ping`
|
|
152
|
+
|
|
153
|
+
After deployment, confirm the key file is publicly reachable:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
curl -fsS https://example.com/<key>.txt
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The response body should be the key only, with no extra text.
|
|
160
|
+
|
|
161
|
+
Key discovery only searches `config.storage`. For example, when sitemap storage
|
|
162
|
+
points at `storage/sitemaps`, a verification file in Rails' `public/` directory
|
|
163
|
+
will not be discovered. Use the configuration below for that arrangement.
|
|
164
|
+
|
|
165
|
+
### Configured Key
|
|
166
|
+
|
|
167
|
+
If the host app already stores an IndexNow key in credentials or environment
|
|
168
|
+
variables, configure it directly:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
Indexmap.configure do |config|
|
|
172
|
+
config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
When `config.index_now.key` is set, `indexmap:sitemap:create` also ensures the
|
|
177
|
+
matching `<key>.txt` verification file exists in the configured storage. It
|
|
178
|
+
leaves an existing valid key file unchanged.
|
|
179
|
+
|
|
180
|
+
If the verification file uses a non-standard name, configure it explicitly:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
Indexmap.configure do |config|
|
|
184
|
+
config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
|
|
185
|
+
config.index_now.key_filename = -> { ENV.fetch("INDEXNOW_KEY") + ".txt" }
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
If another part of the host app owns the verification file, disable automatic
|
|
190
|
+
key-file writes:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
Indexmap.configure do |config|
|
|
194
|
+
config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
|
|
195
|
+
config.index_now.write_key_file = false
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Rails Public Key File With Separate Sitemap Storage
|
|
200
|
+
|
|
201
|
+
If Rails serves an existing verification file from `public/` while sitemaps
|
|
202
|
+
live in `storage/sitemaps` or another backend, configure the key explicitly and
|
|
203
|
+
keep the host app responsible for serving the file:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
Indexmap.configure do |config|
|
|
207
|
+
config.base_url = -> { "https://www.example.com" }
|
|
208
|
+
config.storage = lambda {
|
|
209
|
+
Indexmap::Storage::Filesystem.new(
|
|
210
|
+
path: Rails.root.join("storage/sitemaps"),
|
|
211
|
+
public_url: config.base_url
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
config.index_now.key = -> {
|
|
215
|
+
Rails.public_path.join("0123456789abcdef0123456789abcdef.txt").read.strip
|
|
216
|
+
}
|
|
217
|
+
config.index_now.write_key_file = false
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Replace the example filename with your existing `<key>.txt` filename. For a new
|
|
222
|
+
key, generate one with `ruby -rsecurerandom -e 'print SecureRandom.hex(16)'`, then
|
|
223
|
+
create `public/<key>.txt` containing exactly that key, without a trailing newline.
|
|
224
|
+
Deploy the file with the application.
|
|
225
|
+
|
|
226
|
+
`write_key_file = false` prevents sitemap generation from writing a second key
|
|
227
|
+
file into sitemap storage. It does not change where the pinger searches for a
|
|
228
|
+
key, so `config.index_now.key` must also be set.
|
|
229
|
+
|
|
230
|
+
The pinger builds `keyLocation` using
|
|
231
|
+
`config.storage.public_url(config.index_now.key_filename)`. In this example,
|
|
232
|
+
that URL is `https://www.example.com/<key>.txt`. Ensure the storage adapter
|
|
233
|
+
returns the actual public verification URL; adapters with a different public
|
|
234
|
+
origin or path need a matching publicly served file there.
|
|
235
|
+
|
|
236
|
+
The host app must also serve the sitemap files at their configured public URLs.
|
|
237
|
+
A filesystem adapter writes files but does not create Rails routes for them.
|
|
238
|
+
|
|
239
|
+
Before submitting URLs, verify the deployed file and run a dry run in the
|
|
240
|
+
production environment:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
curl -fsS https://www.example.com/<key>.txt
|
|
244
|
+
RAILS_ENV=production bin/rails indexmap:sitemap:create
|
|
245
|
+
RAILS_ENV=production bin/rails indexmap:sitemap:validate
|
|
246
|
+
RAILS_ENV=production INDEXNOW_DRY_RUN=1 bin/rails indexmap:index_now:ping
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The curl response must contain exactly the configured key. A dry run checks
|
|
250
|
+
local key loading and sitemap parsing; it does not verify that IndexNow can
|
|
251
|
+
fetch the public file. After those checks, run
|
|
252
|
+
`RAILS_ENV=production bin/rails indexmap:index_now:ping` to submit the URLs.
|
|
253
|
+
|
|
254
|
+
### Ping IndexNow
|
|
255
|
+
|
|
256
|
+
#### Inspect selection and actual attempts
|
|
257
|
+
|
|
258
|
+
Preview the exact selection without submitting or requiring a key:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
SINCE=2026-04-18T10:30:00Z bin/rails indexmap:index_now:plan
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The task writes one JSON object per URL, including excluded URLs. Each row has
|
|
265
|
+
`loc`, `lastmod`, `source_sitemap`, `index_filename`, the resolved UTC `cutoff`,
|
|
266
|
+
`selected`, `reason`, the selected `batch` number, and `missing_key`. Reasons are
|
|
267
|
+
`all`, `recent`, `missing_lastmod`, and `before_cutoff`. An empty root discovery
|
|
268
|
+
produces no rows. Invalid reachable sitemaps raise before selection or sending.
|
|
269
|
+
Planning reads configured storage only; it makes no network requests or writes.
|
|
270
|
+
|
|
271
|
+
To record which URLs a real submission attempts, use:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
INDEXNOW_REPORT=1 bin/rails indexmap:index_now:ping > /private/operator-path/indexnow-attempts.jsonl
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Use an existing private destination outside `public/` and sitemap storage.
|
|
278
|
+
The report replaces the task's human-readable output with JSON Lines:
|
|
279
|
+
`attempt_started` contains the batch number and exact `urls`; `attempt_finished`
|
|
280
|
+
adds its status and HTTP receipt; `summary` retains the normal result totals.
|
|
281
|
+
HTTP 202 has receipt `validation_pending`; other successful HTTP responses have
|
|
282
|
+
receipt `received`. Neither proves crawling or indexing. Dry runs emit only a
|
|
283
|
+
summary because they make no attempts.
|
|
284
|
+
|
|
285
|
+
Timeouts and connection failures produce a failed batch with reason
|
|
286
|
+
`unknown_delivery`. Earlier successful batches remain in the summary. There are no
|
|
287
|
+
automatic retries by default. Requests use a five-second connection timeout and
|
|
288
|
+
a 20-second request timeout. API keys are omitted from reports.
|
|
289
|
+
|
|
290
|
+
Enable bounded retries explicitly in the existing configure block:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
config.index_now.max_attempts = 3
|
|
294
|
+
config.index_now.max_wait = 30
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Attempts must be an integer from 1 through 5; `max_wait` is a finite number of
|
|
298
|
+
seconds from 0 through 60. The default is one attempt and a 30-second wait budget.
|
|
299
|
+
Only the affected batch retries for transport uncertainty or HTTP 429, 500, 502,
|
|
300
|
+
503, and 504. Backoff includes jitter. A valid `Retry-After` takes precedence;
|
|
301
|
+
when it exceeds `max_wait`, the batch remains failed instead of retrying early.
|
|
302
|
+
Permanent errors and HTTP 202 do not retry. Both report events include an
|
|
303
|
+
`attempt` number, so an initially uncertain attempt remains visible even when a
|
|
304
|
+
later attempt succeeds. Summary counts describe final batch outcomes.
|
|
305
|
+
|
|
306
|
+
Do not stack whole-job retries around a partially successful submission: that can
|
|
307
|
+
resend earlier successful batches. Retries do not survive process restarts and
|
|
308
|
+
may duplicate a remotely received request after a timeout. Generation and
|
|
309
|
+
submission remain separate. Preview and sending validate and spool the complete
|
|
310
|
+
reachable inventory before yielding selection or issuing requests; selected URLs
|
|
311
|
+
are then read in bounded batches instead of retaining an inventory-sized array.
|
|
312
|
+
|
|
313
|
+
Exports are not a durable ledger. A preview and a later submission can differ
|
|
314
|
+
when files or configuration change. An attempt-started record proves local
|
|
315
|
+
intent, not remote receipt; a crash or timeout can leave delivery uncertain.
|
|
316
|
+
Retain reports yourself if historical visibility is required.
|
|
317
|
+
|
|
318
|
+
Ruby callers can use `pinger.plan.each` for selection rows and
|
|
319
|
+
`pinger.ping { |event| ... }` for attempt events without retaining another URL
|
|
320
|
+
inventory. Exceptions raised by the reporting block propagate to the caller.
|
|
321
|
+
|
|
322
|
+
#### Submit selected URLs
|
|
323
|
+
|
|
324
|
+
Run:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
bin/rails indexmap:sitemap:create
|
|
328
|
+
bin/rails indexmap:sitemap:validate
|
|
329
|
+
bin/rails indexmap:index_now:ping
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
For a dry run:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
INDEXNOW_DRY_RUN=1 bin/rails indexmap:index_now:ping
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
By default, `indexmap:index_now:ping` submits all current sitemap URLs. To limit
|
|
339
|
+
the submission to recently changed sitemap entries, set one of:
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
SINCE=2026-04-18T10:30:00Z bin/rails indexmap:index_now:ping
|
|
343
|
+
INDEXNOW_RECENT_HOURS=24 bin/rails indexmap:index_now:ping
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`SINCE` takes precedence; invalid values raise rather than falling back. Date-only
|
|
347
|
+
`lastmod` values are compared to the cutoff's UTC calendar date, including that
|
|
348
|
+
whole day. Missing dates are always included, so this filter is not a precise
|
|
349
|
+
content-change detector. Batch sizes default to 500 and must be integers from
|
|
350
|
+
1 through 10,000, configured with `config.index_now.max_urls_per_request` or
|
|
351
|
+
`INDEXNOW_MAX_URLS_PER_REQUEST`.
|
|
352
|
+
|
|
353
|
+
If the task says the key is missing, either configure `config.index_now.key` or
|
|
354
|
+
run `indexmap:index_now:write_key` and deploy the generated key file. If a key
|
|
355
|
+
file already exists in `public/`, check whether `config.storage` points at a
|
|
356
|
+
different directory; use the separate-storage example above in that case. If the
|
|
357
|
+
remote endpoint returns `403`, verify that the public key file URL returns the
|
|
358
|
+
same key that `indexmap` is submitting.
|
|
359
|
+
|
|
360
|
+
## References
|
|
361
|
+
|
|
362
|
+
- [Google Search Console Sitemaps API](https://developers.google.com/webmaster-tools/v1/sitemaps/submit)
|
|
363
|
+
- [Google Search Console API reference](https://developers.google.com/webmaster-tools/v1/api_reference_index)
|
|
364
|
+
- [Google Indexing API urlNotifications reference](https://developers.google.com/search/apis/indexing-api/v3/reference/indexing/rest/v3/urlNotifications/)
|
|
365
|
+
- [Google Indexing API usage limits](https://developers.google.com/search/apis/indexing-api/v3/using-api)
|
|
366
|
+
- [Google Cloud service account keys](https://cloud.google.com/iam/docs/keys-create-delete)
|
|
367
|
+
- [Search Console users and permissions](https://support.google.com/webmasters/answer/7687615)
|
|
368
|
+
- [IndexNow protocol documentation](https://www.indexnow.org/documentation)
|