indexmap 0.4.0 → 0.4.2
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 +3 -3
- data/lib/indexmap/pinger/google.rb +40 -5
- data/lib/indexmap/pinger/index_now.rb +82 -16
- data/lib/indexmap/version.rb +1 -1
- data/lib/indexmap/writer.rb +3 -2
- data/lib/tasks/indexmap_tasks.rake +70 -2
- data/test/indexmap/pinger/google_test.rb +75 -3
- data/test/indexmap/pinger/index_now_test.rb +144 -13
- data/test/indexmap/task_runner_test.rb +7 -5
- data/test/indexmap/writer_test.rb +19 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b0f3c8b9175d0c18c39e52b61b3934129dfa8b8b637d06ad3d0d9adc30eb995
|
|
4
|
+
data.tar.gz: 3baf06c566ce17d63e189aabfdd7f3e7b4047d5908fc6a58f3e017da5ecef1de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 700e0cd5485cd433ceaa520a2d634eb4e5ccf93d4d8993224fae61acdf7dacd73c91029e24ff750cc58ce8704a5b00bb4ed66eff113d0693c46df75fc5aea4df
|
|
7
|
+
data.tar.gz: aa51396dad778e50b18a53606d316d5f9008ed358cecb7b75c0f7dc72f24d47a3959750102fdddafe50fbb31688256e16b07ec9c9c0ae53cb9431148593bb250
|
data/CHANGELOG.md
CHANGED
|
@@ -5,12 +5,12 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [0.4.
|
|
8
|
+
## [0.4.2] - 2026-04-23
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### Fixed
|
|
12
12
|
|
|
13
|
-
-
|
|
13
|
+
- harden sitemap pinging and indexnow key handling (#7)
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
|
|
@@ -17,10 +17,13 @@ module Indexmap
|
|
|
17
17
|
def ping
|
|
18
18
|
if google_configuration.credentials.to_s.strip.empty?
|
|
19
19
|
logger.debug("Google sitemap credentials not configured.")
|
|
20
|
-
return
|
|
20
|
+
return {status: :skipped, reason: :missing_credentials}
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
results = sitemap_files.map { |sitemap_file| ping_sitemap(sitemap_file) }
|
|
24
|
+
return {status: :skipped, reason: :no_sitemaps} if results.empty?
|
|
25
|
+
|
|
26
|
+
summarize_results(results)
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
private
|
|
@@ -35,18 +38,31 @@ module Indexmap
|
|
|
35
38
|
sitemap_url = URI.join(host, File.basename(sitemap_file)).to_s
|
|
36
39
|
|
|
37
40
|
unless authorized?
|
|
38
|
-
logger.
|
|
39
|
-
return
|
|
41
|
+
logger.debug("Google Search Console does not have access to the site: #{root_domain}")
|
|
42
|
+
return {
|
|
43
|
+
status: :failed,
|
|
44
|
+
reason: :unauthorized,
|
|
45
|
+
property: property_identifier,
|
|
46
|
+
root_domain: root_domain
|
|
47
|
+
}
|
|
40
48
|
end
|
|
41
49
|
|
|
42
50
|
webmasters_service.submit_sitemap(property_identifier, sitemap_url)
|
|
43
51
|
logger.debug { "Successfully pinged Google with sitemap: #{sitemap_url}" }
|
|
52
|
+
{status: :submitted, sitemap_url: sitemap_url}
|
|
44
53
|
rescue ::Google::Apis::ClientError => e
|
|
45
54
|
logger.debug { "Failed to ping Google for #{sitemap_url}. Status: #{e.status_code}, Body: #{e.body}" }
|
|
55
|
+
{
|
|
56
|
+
status: :failed,
|
|
57
|
+
reason: :client_error,
|
|
58
|
+
sitemap_url: sitemap_url,
|
|
59
|
+
status_code: e.status_code,
|
|
60
|
+
body: e.body
|
|
61
|
+
}
|
|
46
62
|
end
|
|
47
63
|
|
|
48
64
|
def authorized?
|
|
49
|
-
|
|
65
|
+
@authorized ||= accessible_site_urls.include?(property_identifier)
|
|
50
66
|
end
|
|
51
67
|
|
|
52
68
|
def property_identifier
|
|
@@ -73,6 +89,25 @@ module Indexmap
|
|
|
73
89
|
scope: scope
|
|
74
90
|
)
|
|
75
91
|
end
|
|
92
|
+
|
|
93
|
+
def summarize_results(results)
|
|
94
|
+
submitted = results.select { |result| result[:status] == :submitted }
|
|
95
|
+
failures = results.select { |result| result[:status] == :failed }
|
|
96
|
+
|
|
97
|
+
return {status: :submitted, sitemap_count: submitted.count, submitted: submitted} if failures.empty?
|
|
98
|
+
return {status: :failed, sitemap_count: 0, failures: failures} if submitted.empty?
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
status: :partial,
|
|
102
|
+
sitemap_count: submitted.count,
|
|
103
|
+
submitted: submitted,
|
|
104
|
+
failures: failures
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def accessible_site_urls
|
|
109
|
+
@accessible_site_urls ||= Array(webmasters_service.list_sites.site_entry).map(&:site_url)
|
|
110
|
+
end
|
|
76
111
|
end
|
|
77
112
|
end
|
|
78
113
|
end
|
|
@@ -8,6 +8,8 @@ require "time"
|
|
|
8
8
|
module Indexmap
|
|
9
9
|
module Pinger
|
|
10
10
|
class IndexNow < Base
|
|
11
|
+
KEY_FORMAT = /\A[a-f0-9]{32}\z/
|
|
12
|
+
|
|
11
13
|
def initialize(configuration: Indexmap.configuration, connection: nil)
|
|
12
14
|
super(configuration: configuration)
|
|
13
15
|
@connection = connection
|
|
@@ -17,42 +19,45 @@ module Indexmap
|
|
|
17
19
|
api_key = read_api_key
|
|
18
20
|
unless api_key
|
|
19
21
|
logger.debug("IndexNow API key is not configured.")
|
|
20
|
-
return
|
|
22
|
+
return {status: :skipped, reason: :missing_key}
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
entries = entries_to_ping
|
|
24
26
|
if entries.empty?
|
|
25
27
|
logger.debug("IndexNow: no URLs matched the current filter.")
|
|
26
|
-
return
|
|
28
|
+
return {status: :skipped, reason: :no_urls}
|
|
27
29
|
end
|
|
28
30
|
|
|
29
|
-
entries.each_slice(max_urls_per_request) do |batch|
|
|
31
|
+
results = entries.each_slice(max_urls_per_request).map do |batch|
|
|
30
32
|
urls = batch.map(&:loc)
|
|
31
33
|
|
|
32
34
|
if dry_run?
|
|
33
35
|
logger.debug { "IndexNow dry-run: would ping #{urls.count} URLs." }
|
|
34
|
-
next
|
|
36
|
+
next({status: :dry_run, url_count: urls.count})
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
submit_batch(api_key: api_key, urls: urls)
|
|
38
40
|
end
|
|
41
|
+
|
|
42
|
+
summarize_results(results)
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
def write_key_file(key: index_now_configuration.key, path: nil)
|
|
42
|
-
key = key
|
|
46
|
+
key = normalized_configured_key(key)
|
|
43
47
|
return if key.empty?
|
|
44
48
|
|
|
45
49
|
path ||= index_now_configuration.key_path(public_path: configuration.public_path, key: key)
|
|
46
50
|
FileUtils.mkdir_p(path.dirname)
|
|
47
|
-
File.write(path,
|
|
51
|
+
File.write(path, key)
|
|
48
52
|
path
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
def ensure_key_file
|
|
52
|
-
configured_key = index_now_configuration.key
|
|
56
|
+
configured_key = normalized_configured_key(index_now_configuration.key)
|
|
53
57
|
return write_key_file(key: configured_key) unless configured_key.empty?
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
existing_path = existing_key_file
|
|
60
|
+
return existing_path if existing_path
|
|
56
61
|
|
|
57
62
|
key = generated_key
|
|
58
63
|
write_key_file(key: key, path: configuration.public_path.join("#{key}.txt"))
|
|
@@ -141,6 +146,9 @@ module Indexmap
|
|
|
141
146
|
|
|
142
147
|
def submit_batch(api_key:, urls:)
|
|
143
148
|
payload = {host: hostname, key: api_key, urlList: urls}
|
|
149
|
+
location = key_location(api_key: api_key)
|
|
150
|
+
payload[:keyLocation] = location if location
|
|
151
|
+
|
|
144
152
|
response = index_now_connection.post("/indexnow") do |request|
|
|
145
153
|
request.headers["Content-Type"] = "application/json"
|
|
146
154
|
request.body = payload.to_json
|
|
@@ -148,10 +156,10 @@ module Indexmap
|
|
|
148
156
|
|
|
149
157
|
if response.success?
|
|
150
158
|
logger.debug { "Successfully pinged IndexNow with #{urls.count} URLs." }
|
|
151
|
-
|
|
159
|
+
{status: :submitted, url_count: urls.count}
|
|
152
160
|
else
|
|
153
161
|
logger.debug { "Failed to ping IndexNow. Status: #{response.status}, Body: #{response.body}" }
|
|
154
|
-
|
|
162
|
+
{status: :failed, url_count: urls.count, status_code: response.status, body: response.body}
|
|
155
163
|
end
|
|
156
164
|
end
|
|
157
165
|
|
|
@@ -166,30 +174,88 @@ module Indexmap
|
|
|
166
174
|
end
|
|
167
175
|
|
|
168
176
|
def read_api_key
|
|
169
|
-
configured_key = index_now_configuration.key
|
|
177
|
+
configured_key = normalized_configured_key(index_now_configuration.key)
|
|
170
178
|
return configured_key unless configured_key.empty?
|
|
171
179
|
|
|
172
|
-
existing_key_file&.read
|
|
180
|
+
existing_key_file&.read
|
|
173
181
|
end
|
|
174
182
|
|
|
175
183
|
def existing_key_file
|
|
176
184
|
configured_path = index_now_configuration.key_path(public_path: configuration.public_path)
|
|
177
185
|
return configured_path if valid_key_file?(configured_path)
|
|
178
186
|
|
|
179
|
-
configuration.public_path.glob("*.txt").find { |file| valid_key_file?(file) }
|
|
187
|
+
configuration.public_path.glob("*.txt").sort.find { |file| valid_key_file?(file) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def key_location(api_key:)
|
|
191
|
+
path = index_now_configuration.key_path(public_path: configuration.public_path, key: api_key) || existing_key_file
|
|
192
|
+
return unless path
|
|
193
|
+
|
|
194
|
+
public_path = configuration.public_path.expand_path
|
|
195
|
+
key_path = path.expand_path
|
|
196
|
+
relative_path = key_path.relative_path_from(public_path)
|
|
197
|
+
|
|
198
|
+
URI.join("#{host}/", relative_path.to_s).to_s
|
|
199
|
+
rescue ArgumentError
|
|
200
|
+
nil
|
|
180
201
|
end
|
|
181
202
|
|
|
182
203
|
def valid_key_file?(path)
|
|
183
204
|
return false unless path&.file?
|
|
184
205
|
|
|
185
206
|
filename = path.basename(".txt").to_s
|
|
186
|
-
return false unless filename.match?(
|
|
207
|
+
return false unless filename.match?(KEY_FORMAT)
|
|
187
208
|
|
|
188
|
-
path.read
|
|
209
|
+
path.read == filename
|
|
189
210
|
end
|
|
190
211
|
|
|
191
212
|
def generated_key
|
|
192
|
-
SecureRandom.
|
|
213
|
+
SecureRandom.hex(16)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def normalized_configured_key(value)
|
|
217
|
+
key = value.to_s.strip
|
|
218
|
+
return key if key.empty? || key.match?(KEY_FORMAT)
|
|
219
|
+
|
|
220
|
+
raise ConfigurationError, "IndexNow key must be a 32-character lowercase hexadecimal string"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def summarize_results(results)
|
|
224
|
+
dry_runs = results.select { |result| result[:status] == :dry_run }
|
|
225
|
+
submitted = results.select { |result| result[:status] == :submitted }
|
|
226
|
+
failures = results.select { |result| result[:status] == :failed }
|
|
227
|
+
|
|
228
|
+
if dry_runs.any?
|
|
229
|
+
return {
|
|
230
|
+
status: :dry_run,
|
|
231
|
+
url_count: dry_runs.sum { |result| result[:url_count] },
|
|
232
|
+
batch_count: dry_runs.count
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if failures.empty?
|
|
237
|
+
return {
|
|
238
|
+
status: :submitted,
|
|
239
|
+
url_count: submitted.sum { |result| result[:url_count] },
|
|
240
|
+
batch_count: submitted.count
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if submitted.empty?
|
|
245
|
+
return {
|
|
246
|
+
status: :failed,
|
|
247
|
+
url_count: 0,
|
|
248
|
+
batch_count: 0,
|
|
249
|
+
failures: failures
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
status: :partial,
|
|
255
|
+
url_count: submitted.sum { |result| result[:url_count] },
|
|
256
|
+
batch_count: submitted.count,
|
|
257
|
+
failures: failures
|
|
258
|
+
}
|
|
193
259
|
end
|
|
194
260
|
end
|
|
195
261
|
end
|
data/lib/indexmap/version.rb
CHANGED
data/lib/indexmap/writer.rb
CHANGED
|
@@ -82,7 +82,8 @@ module Indexmap
|
|
|
82
82
|
sitemap_sections.each do |section|
|
|
83
83
|
lines << " <sitemap>"
|
|
84
84
|
lines << " <loc>#{escape(index_loc(section.filename))}</loc>"
|
|
85
|
-
|
|
85
|
+
lastmod = section_lastmod(section)
|
|
86
|
+
lines << " <lastmod>#{format_lastmod(lastmod)}</lastmod>" if lastmod
|
|
86
87
|
lines << " </sitemap>"
|
|
87
88
|
end
|
|
88
89
|
|
|
@@ -102,7 +103,7 @@ module Indexmap
|
|
|
102
103
|
|
|
103
104
|
def section_lastmod(section)
|
|
104
105
|
timestamps = Array(section.entries).map { |entry| comparable_lastmod(normalize_entry(entry).lastmod) }.compact
|
|
105
|
-
timestamps.max
|
|
106
|
+
timestamps.max
|
|
106
107
|
end
|
|
107
108
|
|
|
108
109
|
def format_lastmod(value)
|
|
@@ -35,14 +35,40 @@ namespace :sitemap do
|
|
|
35
35
|
namespace :google do
|
|
36
36
|
desc "Ping Google Search Console"
|
|
37
37
|
task ping: :environment do
|
|
38
|
-
Indexmap::Pinger::Google.new.ping
|
|
38
|
+
result = Indexmap::Pinger::Google.new.ping
|
|
39
|
+
|
|
40
|
+
case result[:status]
|
|
41
|
+
when :submitted
|
|
42
|
+
puts "Submitted #{result[:sitemap_count]} sitemap #{(result[:sitemap_count] == 1) ? "file" : "files"} to Google Search Console."
|
|
43
|
+
when :partial
|
|
44
|
+
puts "Submitted #{result[:sitemap_count]} sitemap #{(result[:sitemap_count] == 1) ? "file" : "files"} to Google Search Console, with #{result[:failures].count} failure#{"s" unless result[:failures].count == 1}."
|
|
45
|
+
result[:failures].each { |failure| puts format_google_ping_failure(failure) }
|
|
46
|
+
when :failed
|
|
47
|
+
result[:failures].each { |failure| puts format_google_ping_failure(failure) }
|
|
48
|
+
when :skipped
|
|
49
|
+
puts format_google_ping_skip(result)
|
|
50
|
+
end
|
|
39
51
|
end
|
|
40
52
|
end
|
|
41
53
|
|
|
42
54
|
namespace :index_now do
|
|
43
55
|
desc "Ping IndexNow. ENV: SINCE=2026-04-18T10:30:00Z or INDEXNOW_RECENT_HOURS=24"
|
|
44
56
|
task ping: :environment do
|
|
45
|
-
Indexmap::Pinger::IndexNow.new.ping
|
|
57
|
+
result = Indexmap::Pinger::IndexNow.new.ping
|
|
58
|
+
|
|
59
|
+
case result[:status]
|
|
60
|
+
when :submitted
|
|
61
|
+
puts "Submitted #{result[:url_count]} URL#{"s" unless result[:url_count] == 1} to IndexNow in #{result[:batch_count]} request#{"s" unless result[:batch_count] == 1}."
|
|
62
|
+
when :partial
|
|
63
|
+
puts "Submitted #{result[:url_count]} URL#{"s" unless result[:url_count] == 1} to IndexNow in #{result[:batch_count]} request#{"s" unless result[:batch_count] == 1}, with #{result[:failures].count} failure#{"s" unless result[:failures].count == 1}."
|
|
64
|
+
result[:failures].each { |failure| puts format_index_now_ping_failure(failure) }
|
|
65
|
+
when :failed
|
|
66
|
+
result[:failures].each { |failure| puts format_index_now_ping_failure(failure) }
|
|
67
|
+
when :dry_run
|
|
68
|
+
puts "IndexNow dry-run: would submit #{result[:url_count]} URL#{"s" unless result[:url_count] == 1} in #{result[:batch_count]} request#{"s" unless result[:batch_count] == 1}."
|
|
69
|
+
when :skipped
|
|
70
|
+
puts format_index_now_ping_skip(result)
|
|
71
|
+
end
|
|
46
72
|
end
|
|
47
73
|
|
|
48
74
|
desc "Ensure the IndexNow key file exists in public/"
|
|
@@ -64,4 +90,46 @@ namespace :sitemap do
|
|
|
64
90
|
def public_directory(runner)
|
|
65
91
|
runner.public_path
|
|
66
92
|
end
|
|
93
|
+
|
|
94
|
+
def format_google_ping_failure(failure)
|
|
95
|
+
case failure[:reason]
|
|
96
|
+
when :unauthorized
|
|
97
|
+
"Google Search Console does not have access to #{failure[:property]}."
|
|
98
|
+
when :client_error
|
|
99
|
+
"Google ping failed for #{failure[:sitemap_url]} (status #{failure[:status_code]})."
|
|
100
|
+
else
|
|
101
|
+
"Google ping failed."
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def format_google_ping_skip(result)
|
|
106
|
+
case result[:reason]
|
|
107
|
+
when :missing_credentials
|
|
108
|
+
"Google sitemap credentials are not configured; skipped Google submission."
|
|
109
|
+
when :no_sitemaps
|
|
110
|
+
"No sitemap files found; skipped Google submission."
|
|
111
|
+
else
|
|
112
|
+
"Skipped Google submission."
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def format_index_now_ping_failure(failure)
|
|
117
|
+
case failure[:status_code]
|
|
118
|
+
when nil
|
|
119
|
+
"IndexNow submission failed."
|
|
120
|
+
else
|
|
121
|
+
"IndexNow submission failed for #{failure[:url_count]} URL#{"s" unless failure[:url_count] == 1} (status #{failure[:status_code]})."
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_index_now_ping_skip(result)
|
|
126
|
+
case result[:reason]
|
|
127
|
+
when :missing_key
|
|
128
|
+
"IndexNow key is not configured and no valid key file was found; skipped IndexNow submission."
|
|
129
|
+
when :no_urls
|
|
130
|
+
"No sitemap URLs matched the current IndexNow filter; skipped IndexNow submission."
|
|
131
|
+
else
|
|
132
|
+
"Skipped IndexNow submission."
|
|
133
|
+
end
|
|
134
|
+
end
|
|
67
135
|
end
|
|
@@ -8,13 +8,15 @@ class IndexmapPingerGoogleTest < Minitest::Test
|
|
|
8
8
|
|
|
9
9
|
class FakeWebmastersService
|
|
10
10
|
attr_accessor :authorization
|
|
11
|
-
attr_reader :submitted
|
|
11
|
+
attr_reader :submitted, :list_sites_calls
|
|
12
12
|
|
|
13
13
|
def initialize(site_urls:)
|
|
14
14
|
@site_urls = site_urls
|
|
15
|
+
@list_sites_calls = 0
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def list_sites
|
|
19
|
+
@list_sites_calls += 1
|
|
18
20
|
SiteList.new(@site_urls.map { |site_url| SiteEntry.new(site_url) })
|
|
19
21
|
end
|
|
20
22
|
|
|
@@ -40,7 +42,7 @@ class IndexmapPingerGoogleTest < Minitest::Test
|
|
|
40
42
|
:fake_authorizer
|
|
41
43
|
end
|
|
42
44
|
|
|
43
|
-
Indexmap::Pinger::Google.new(
|
|
45
|
+
result = Indexmap::Pinger::Google.new(
|
|
44
46
|
configuration: configuration,
|
|
45
47
|
service: service,
|
|
46
48
|
credentials_builder: credentials_builder
|
|
@@ -49,6 +51,9 @@ class IndexmapPingerGoogleTest < Minitest::Test
|
|
|
49
51
|
assert_equal [["{\"type\":\"service_account\"}", "https://www.googleapis.com/auth/webmasters"]], builder_calls
|
|
50
52
|
assert_equal :fake_authorizer, service.authorization
|
|
51
53
|
assert_equal ["sc-domain:example.com", "https://www.example.com/sitemap.xml"], service.submitted
|
|
54
|
+
assert_equal :submitted, result[:status]
|
|
55
|
+
assert_equal 1, result[:sitemap_count]
|
|
56
|
+
assert_equal 1, service.list_sites_calls
|
|
52
57
|
end
|
|
53
58
|
end
|
|
54
59
|
|
|
@@ -63,9 +68,76 @@ class IndexmapPingerGoogleTest < Minitest::Test
|
|
|
63
68
|
|
|
64
69
|
service = FakeWebmastersService.new(site_urls: ["sc-domain:example.com"])
|
|
65
70
|
|
|
66
|
-
Indexmap::Pinger::Google.new(configuration: configuration, service: service).ping
|
|
71
|
+
result = Indexmap::Pinger::Google.new(configuration: configuration, service: service).ping
|
|
67
72
|
|
|
68
73
|
assert_nil service.submitted
|
|
74
|
+
assert_equal({status: :skipped, reason: :missing_credentials}, result)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_reports_missing_sitemap_files
|
|
79
|
+
Dir.mktmpdir do |dir|
|
|
80
|
+
configuration = Indexmap::Configuration.new
|
|
81
|
+
configuration.base_url = "https://www.example.com"
|
|
82
|
+
configuration.public_path = Pathname(dir)
|
|
83
|
+
configuration.google.credentials = "{\"type\":\"service_account\"}"
|
|
84
|
+
|
|
85
|
+
service = FakeWebmastersService.new(site_urls: ["sc-domain:example.com"])
|
|
86
|
+
result = Indexmap::Pinger::Google.new(
|
|
87
|
+
configuration: configuration,
|
|
88
|
+
service: service,
|
|
89
|
+
credentials_builder: ->(**) { :fake_authorizer }
|
|
90
|
+
).ping
|
|
91
|
+
|
|
92
|
+
assert_equal({status: :skipped, reason: :no_sitemaps}, result)
|
|
93
|
+
assert_nil service.submitted
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_reports_google_authorization_failure
|
|
98
|
+
Dir.mktmpdir do |dir|
|
|
99
|
+
public_path = Pathname(dir)
|
|
100
|
+
public_path.join("sitemap.xml").write("<sitemapindex/>")
|
|
101
|
+
|
|
102
|
+
configuration = Indexmap::Configuration.new
|
|
103
|
+
configuration.base_url = "https://www.example.com"
|
|
104
|
+
configuration.public_path = public_path
|
|
105
|
+
configuration.google.credentials = "{\"type\":\"service_account\"}"
|
|
106
|
+
|
|
107
|
+
service = FakeWebmastersService.new(site_urls: ["sc-domain:not-example.org"])
|
|
108
|
+
result = Indexmap::Pinger::Google.new(
|
|
109
|
+
configuration: configuration,
|
|
110
|
+
service: service,
|
|
111
|
+
credentials_builder: ->(**) { :fake_authorizer }
|
|
112
|
+
).ping
|
|
113
|
+
|
|
114
|
+
assert_equal :failed, result[:status]
|
|
115
|
+
assert_equal 1, result[:failures].count
|
|
116
|
+
assert_equal :unauthorized, result[:failures].first[:reason]
|
|
117
|
+
assert_nil service.submitted
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def test_google_authorization_requires_exact_property_match
|
|
122
|
+
Dir.mktmpdir do |dir|
|
|
123
|
+
public_path = Pathname(dir)
|
|
124
|
+
public_path.join("sitemap.xml").write("<sitemapindex/>")
|
|
125
|
+
|
|
126
|
+
configuration = Indexmap::Configuration.new
|
|
127
|
+
configuration.base_url = "https://www.example.com"
|
|
128
|
+
configuration.public_path = public_path
|
|
129
|
+
configuration.google.credentials = "{\"type\":\"service_account\"}"
|
|
130
|
+
|
|
131
|
+
service = FakeWebmastersService.new(site_urls: ["sc-domain:myexample.com"])
|
|
132
|
+
result = Indexmap::Pinger::Google.new(
|
|
133
|
+
configuration: configuration,
|
|
134
|
+
service: service,
|
|
135
|
+
credentials_builder: ->(**) { :fake_authorizer }
|
|
136
|
+
).ping
|
|
137
|
+
|
|
138
|
+
assert_equal :failed, result[:status]
|
|
139
|
+
assert_equal :unauthorized, result[:failures].first[:reason]
|
|
140
|
+
assert_nil service.submitted
|
|
69
141
|
end
|
|
70
142
|
end
|
|
71
143
|
end
|
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
require "test_helper"
|
|
4
4
|
|
|
5
5
|
class IndexmapPingerIndexNowTest < Minitest::Test
|
|
6
|
+
VALID_KEY = "1234567890abcdef1234567890abcdef"
|
|
7
|
+
|
|
6
8
|
def test_writes_key_file_from_configuration
|
|
7
9
|
Dir.mktmpdir do |dir|
|
|
8
10
|
configuration = Indexmap::Configuration.new
|
|
9
11
|
configuration.base_url = "https://www.example.com"
|
|
10
12
|
configuration.public_path = Pathname(dir)
|
|
11
|
-
configuration.index_now.key =
|
|
13
|
+
configuration.index_now.key = VALID_KEY
|
|
12
14
|
|
|
13
15
|
path = Indexmap::Pinger::IndexNow.new(configuration: configuration).write_key_file
|
|
14
16
|
|
|
15
|
-
assert_equal Pathname(dir).join("
|
|
16
|
-
assert_equal
|
|
17
|
+
assert_equal Pathname(dir).join("#{VALID_KEY}.txt"), path
|
|
18
|
+
assert_equal VALID_KEY, path.read
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
|
|
@@ -25,16 +27,16 @@ class IndexmapPingerIndexNowTest < Minitest::Test
|
|
|
25
27
|
|
|
26
28
|
path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
|
|
27
29
|
|
|
28
|
-
assert_match(/\A[a-
|
|
29
|
-
assert_equal
|
|
30
|
+
assert_match(/\A[a-f0-9]{32}\.txt\z/, path.basename.to_s)
|
|
31
|
+
assert_equal path.basename(".txt").to_s, path.read
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def test_pings_using_existing_key_file_when_key_is_not_configured
|
|
34
36
|
Dir.mktmpdir do |dir|
|
|
35
37
|
public_path = Pathname(dir)
|
|
36
|
-
key_path = public_path.join("
|
|
37
|
-
key_path.write(
|
|
38
|
+
key_path = public_path.join("#{VALID_KEY}.txt")
|
|
39
|
+
key_path.write(VALID_KEY)
|
|
38
40
|
write_sitemap_files(
|
|
39
41
|
public_path,
|
|
40
42
|
marketing_lastmod: "2026-04-18T00:00:00Z",
|
|
@@ -48,12 +50,34 @@ class IndexmapPingerIndexNowTest < Minitest::Test
|
|
|
48
50
|
indexnow_url = "https://api.indexnow.org/indexnow"
|
|
49
51
|
stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
|
|
50
52
|
|
|
51
|
-
Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
53
|
+
result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
52
54
|
|
|
53
55
|
assert_requested(:post, indexnow_url, times: 1) do |request|
|
|
54
56
|
payload = JSON.parse(request.body)
|
|
55
|
-
assert_equal
|
|
57
|
+
assert_equal VALID_KEY, payload.fetch("key")
|
|
58
|
+
assert_equal "https://www.example.com/#{VALID_KEY}.txt", payload.fetch("keyLocation")
|
|
56
59
|
end
|
|
60
|
+
assert_equal :submitted, result[:status]
|
|
61
|
+
assert_equal 2, result[:url_count]
|
|
62
|
+
assert_equal 1, result[:batch_count]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_ignores_existing_key_file_with_trailing_newline
|
|
67
|
+
Dir.mktmpdir do |dir|
|
|
68
|
+
public_path = Pathname(dir)
|
|
69
|
+
invalid_key_path = public_path.join("1234567890abcdef1234567890abcdef.txt")
|
|
70
|
+
invalid_key_path.write("#{VALID_KEY}\n")
|
|
71
|
+
|
|
72
|
+
configuration = Indexmap::Configuration.new
|
|
73
|
+
configuration.base_url = "https://www.example.com"
|
|
74
|
+
configuration.public_path = public_path
|
|
75
|
+
|
|
76
|
+
path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
|
|
77
|
+
|
|
78
|
+
refute_equal invalid_key_path, path
|
|
79
|
+
assert_match(/\A[a-f0-9]{32}\.txt\z/, path.basename.to_s)
|
|
80
|
+
assert_equal path.basename(".txt").to_s, path.read
|
|
57
81
|
end
|
|
58
82
|
end
|
|
59
83
|
|
|
@@ -69,12 +93,12 @@ class IndexmapPingerIndexNowTest < Minitest::Test
|
|
|
69
93
|
configuration = Indexmap::Configuration.new
|
|
70
94
|
configuration.base_url = "https://www.example.com"
|
|
71
95
|
configuration.public_path = public_path
|
|
72
|
-
configuration.index_now.key =
|
|
96
|
+
configuration.index_now.key = VALID_KEY
|
|
73
97
|
|
|
74
98
|
indexnow_url = "https://api.indexnow.org/indexnow"
|
|
75
99
|
stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
|
|
76
100
|
|
|
77
|
-
Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
101
|
+
result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
78
102
|
|
|
79
103
|
assert_requested(:post, indexnow_url, times: 1) do |request|
|
|
80
104
|
payload = JSON.parse(request.body)
|
|
@@ -82,7 +106,11 @@ class IndexmapPingerIndexNowTest < Minitest::Test
|
|
|
82
106
|
"https://www.example.com/pages/features",
|
|
83
107
|
"https://www.example.com/insights/us/restaurants/overview"
|
|
84
108
|
].sort, payload.fetch("urlList").sort
|
|
109
|
+
assert_equal "https://www.example.com/#{VALID_KEY}.txt", payload.fetch("keyLocation")
|
|
85
110
|
end
|
|
111
|
+
assert_equal :submitted, result[:status]
|
|
112
|
+
assert_equal 2, result[:url_count]
|
|
113
|
+
assert_equal 1, result[:batch_count]
|
|
86
114
|
end
|
|
87
115
|
end
|
|
88
116
|
|
|
@@ -98,13 +126,17 @@ class IndexmapPingerIndexNowTest < Minitest::Test
|
|
|
98
126
|
configuration = Indexmap::Configuration.new
|
|
99
127
|
configuration.base_url = "https://www.example.com"
|
|
100
128
|
configuration.public_path = public_path
|
|
101
|
-
configuration.index_now.key =
|
|
129
|
+
configuration.index_now.key = VALID_KEY
|
|
102
130
|
|
|
103
131
|
indexnow_url = "https://api.indexnow.org/indexnow"
|
|
104
132
|
stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
|
|
105
133
|
|
|
106
134
|
with_env("SINCE" => "2026-04-15T00:00:00Z") do
|
|
107
|
-
Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
135
|
+
result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
136
|
+
|
|
137
|
+
assert_equal :submitted, result[:status]
|
|
138
|
+
assert_equal 1, result[:url_count]
|
|
139
|
+
assert_equal 1, result[:batch_count]
|
|
108
140
|
end
|
|
109
141
|
|
|
110
142
|
assert_requested(:post, indexnow_url, times: 1) do |request|
|
|
@@ -114,6 +146,105 @@ class IndexmapPingerIndexNowTest < Minitest::Test
|
|
|
114
146
|
end
|
|
115
147
|
end
|
|
116
148
|
|
|
149
|
+
def test_skips_indexnow_ping_when_key_is_missing
|
|
150
|
+
Dir.mktmpdir do |dir|
|
|
151
|
+
public_path = Pathname(dir)
|
|
152
|
+
write_sitemap_files(
|
|
153
|
+
public_path,
|
|
154
|
+
marketing_lastmod: "2026-04-18T00:00:00Z",
|
|
155
|
+
insights_lastmod: "2026-04-10T00:00:00Z"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
configuration = Indexmap::Configuration.new
|
|
159
|
+
configuration.base_url = "https://www.example.com"
|
|
160
|
+
configuration.public_path = public_path
|
|
161
|
+
|
|
162
|
+
result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
163
|
+
|
|
164
|
+
assert_equal({status: :skipped, reason: :missing_key}, result)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def test_reports_indexnow_dry_run
|
|
169
|
+
Dir.mktmpdir do |dir|
|
|
170
|
+
public_path = Pathname(dir)
|
|
171
|
+
write_sitemap_files(
|
|
172
|
+
public_path,
|
|
173
|
+
marketing_lastmod: "2026-04-18T00:00:00Z",
|
|
174
|
+
insights_lastmod: "2026-04-10T00:00:00Z"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
configuration = Indexmap::Configuration.new
|
|
178
|
+
configuration.base_url = "https://www.example.com"
|
|
179
|
+
configuration.public_path = public_path
|
|
180
|
+
configuration.index_now.key = VALID_KEY
|
|
181
|
+
|
|
182
|
+
with_env("INDEXNOW_DRY_RUN" => "1") do
|
|
183
|
+
result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
184
|
+
|
|
185
|
+
assert_equal :dry_run, result[:status]
|
|
186
|
+
assert_equal 2, result[:url_count]
|
|
187
|
+
assert_equal 1, result[:batch_count]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def test_reports_failed_indexnow_submission
|
|
193
|
+
Dir.mktmpdir do |dir|
|
|
194
|
+
public_path = Pathname(dir)
|
|
195
|
+
write_sitemap_files(
|
|
196
|
+
public_path,
|
|
197
|
+
marketing_lastmod: "2026-04-18T00:00:00Z",
|
|
198
|
+
insights_lastmod: "2026-04-10T00:00:00Z"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
configuration = Indexmap::Configuration.new
|
|
202
|
+
configuration.base_url = "https://www.example.com"
|
|
203
|
+
configuration.public_path = public_path
|
|
204
|
+
configuration.index_now.key = VALID_KEY
|
|
205
|
+
|
|
206
|
+
indexnow_url = "https://api.indexnow.org/indexnow"
|
|
207
|
+
stub_request(:post, indexnow_url).to_return(status: 500, body: "boom", headers: {})
|
|
208
|
+
|
|
209
|
+
result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
210
|
+
|
|
211
|
+
assert_equal :failed, result[:status]
|
|
212
|
+
assert_equal 1, result[:failures].count
|
|
213
|
+
assert_equal 500, result[:failures].first[:status_code]
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def test_rejects_invalid_configured_key
|
|
218
|
+
Dir.mktmpdir do |dir|
|
|
219
|
+
configuration = Indexmap::Configuration.new
|
|
220
|
+
configuration.base_url = "https://www.example.com"
|
|
221
|
+
configuration.public_path = Pathname(dir)
|
|
222
|
+
configuration.index_now.key = "test-key"
|
|
223
|
+
|
|
224
|
+
error = assert_raises(Indexmap::ConfigurationError) do
|
|
225
|
+
Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
assert_equal "IndexNow key must be a 32-character lowercase hexadecimal string", error.message
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def test_reuses_existing_key_file_deterministically
|
|
233
|
+
Dir.mktmpdir do |dir|
|
|
234
|
+
public_path = Pathname(dir)
|
|
235
|
+
public_path.join("ffffffffffffffffffffffffffffffff.txt").write("ffffffffffffffffffffffffffffffff")
|
|
236
|
+
public_path.join("00000000000000000000000000000000.txt").write("00000000000000000000000000000000")
|
|
237
|
+
|
|
238
|
+
configuration = Indexmap::Configuration.new
|
|
239
|
+
configuration.base_url = "https://www.example.com"
|
|
240
|
+
configuration.public_path = public_path
|
|
241
|
+
|
|
242
|
+
path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
|
|
243
|
+
|
|
244
|
+
assert_equal public_path.join("00000000000000000000000000000000.txt"), path
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
117
248
|
private
|
|
118
249
|
|
|
119
250
|
def with_env(overrides)
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "test_helper"
|
|
4
4
|
|
|
5
5
|
class IndexmapTaskRunnerTest < Minitest::Test
|
|
6
|
+
VALID_KEY = "1234567890abcdef1234567890abcdef"
|
|
7
|
+
|
|
6
8
|
def test_create_removes_existing_sitemap_files_writes_new_sitemap_and_key_file
|
|
7
9
|
Dir.mktmpdir do |dir|
|
|
8
10
|
public_path = Pathname(dir)
|
|
@@ -18,15 +20,15 @@ class IndexmapTaskRunnerTest < Minitest::Test
|
|
|
18
20
|
entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
|
|
19
21
|
)
|
|
20
22
|
]
|
|
21
|
-
configuration.index_now.key =
|
|
23
|
+
configuration.index_now.key = VALID_KEY
|
|
22
24
|
|
|
23
25
|
result = Indexmap::TaskRunner.new(configuration: configuration).create
|
|
24
26
|
|
|
25
27
|
assert_equal false, public_path.join("sitemap-pages.xml.gz").exist?
|
|
26
28
|
assert_includes public_path.join("sitemap.xml").read, "<sitemapindex"
|
|
27
|
-
assert_equal
|
|
29
|
+
assert_equal VALID_KEY, public_path.join("#{VALID_KEY}.txt").read
|
|
28
30
|
assert_equal [public_path.join("sitemap-pages.xml").to_s, public_path.join("sitemap.xml").to_s], result[:files]
|
|
29
|
-
assert_equal public_path.join("
|
|
31
|
+
assert_equal public_path.join("#{VALID_KEY}.txt"), result[:index_now_key_path]
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
34
|
|
|
@@ -56,8 +58,8 @@ class IndexmapTaskRunnerTest < Minitest::Test
|
|
|
56
58
|
|
|
57
59
|
result = Indexmap::TaskRunner.new(configuration: configuration).write_index_now_key(generate_if_missing: true)
|
|
58
60
|
|
|
59
|
-
assert_match(/\A[a-
|
|
60
|
-
assert_equal
|
|
61
|
+
assert_match(/\A[a-f0-9]{32}\.txt\z/, result.basename.to_s)
|
|
62
|
+
assert_equal result.basename(".txt").to_s, result.read
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
65
|
end
|
|
@@ -77,4 +77,23 @@ class IndexmapWriterTest < Minitest::Test
|
|
|
77
77
|
refute File.exist?(File.join(directory, "sitemap-pages.xml"))
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
|
+
|
|
81
|
+
def test_omits_sitemap_index_lastmod_when_sections_have_no_lastmod
|
|
82
|
+
Dir.mktmpdir do |directory|
|
|
83
|
+
Indexmap::Writer.new(
|
|
84
|
+
sections: [
|
|
85
|
+
Indexmap::Section.new(
|
|
86
|
+
filename: "sitemap-pages.xml",
|
|
87
|
+
entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
|
|
88
|
+
)
|
|
89
|
+
],
|
|
90
|
+
public_path: directory,
|
|
91
|
+
base_url: "https://example.com"
|
|
92
|
+
).write
|
|
93
|
+
|
|
94
|
+
index_xml = File.read(File.join(directory, "sitemap.xml"))
|
|
95
|
+
|
|
96
|
+
refute_includes index_xml, "<lastmod>"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
80
99
|
end
|