indexmap 0.2.1 → 0.3.1
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 +8 -3
- data/README.md +47 -0
- data/lib/indexmap/configuration.rb +8 -0
- data/lib/indexmap/google_configuration.rb +21 -0
- data/lib/indexmap/index_now_configuration.rb +45 -0
- data/lib/indexmap/parser.rb +202 -0
- data/lib/indexmap/path.rb +43 -0
- data/lib/indexmap/pinger/base.rb +58 -0
- data/lib/indexmap/pinger/google.rb +78 -0
- data/lib/indexmap/pinger/index_now.rb +173 -0
- data/lib/indexmap/task_runner.rb +9 -0
- data/lib/indexmap/validator.rb +41 -0
- data/lib/indexmap/version.rb +1 -1
- data/lib/indexmap.rb +9 -0
- data/lib/tasks/indexmap_tasks.rake +32 -0
- data/test/indexmap/configuration_test.rb +14 -0
- data/test/indexmap/parser_test.rb +81 -0
- data/test/indexmap/path_test.rb +28 -0
- data/test/indexmap/pinger/google_test.rb +71 -0
- data/test/indexmap/pinger/index_now_test.rb +118 -0
- data/test/indexmap/task_runner_test.rb +48 -0
- data/test/indexmap/validator_test.rb +68 -0
- data/test/test_helper.rb +8 -0
- metadata +85 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Indexmap
|
|
8
|
+
module Pinger
|
|
9
|
+
class IndexNow < Base
|
|
10
|
+
def initialize(configuration: Indexmap.configuration, connection: nil)
|
|
11
|
+
super(configuration: configuration)
|
|
12
|
+
@connection = connection
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ping
|
|
16
|
+
api_key = read_api_key
|
|
17
|
+
unless api_key
|
|
18
|
+
logger.debug("IndexNow API key is not configured.")
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
entries = entries_to_ping
|
|
23
|
+
if entries.empty?
|
|
24
|
+
logger.debug("IndexNow: no URLs matched the current filter.")
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
entries.each_slice(max_urls_per_request) do |batch|
|
|
29
|
+
urls = batch.map(&:loc)
|
|
30
|
+
|
|
31
|
+
if dry_run?
|
|
32
|
+
logger.debug { "IndexNow dry-run: would ping #{urls.count} URLs." }
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
submit_batch(api_key: api_key, urls: urls)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write_key_file
|
|
41
|
+
key = index_now_configuration.key.to_s.strip
|
|
42
|
+
return if key.empty?
|
|
43
|
+
|
|
44
|
+
path = index_now_configuration.key_path(public_path: configuration.public_path)
|
|
45
|
+
FileUtils.mkdir_p(path.dirname)
|
|
46
|
+
File.write(path, "#{key}\n")
|
|
47
|
+
path
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :connection
|
|
53
|
+
|
|
54
|
+
def index_now_configuration
|
|
55
|
+
configuration.index_now
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sitemap_files
|
|
59
|
+
files = super
|
|
60
|
+
return files if files.one?
|
|
61
|
+
|
|
62
|
+
child_files = files.reject { |file| File.basename(file) == configuration.index_filename }
|
|
63
|
+
child_files.empty? ? files : child_files
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def entries_to_ping
|
|
67
|
+
cutoff = since_cutoff
|
|
68
|
+
unless cutoff
|
|
69
|
+
logger.debug("IndexNow: no cutoff provided, submitting all sitemap URLs.")
|
|
70
|
+
return current_entries.values
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
logger.debug { "IndexNow: submitting sitemap URLs with lastmod >= #{cutoff.iso8601}." }
|
|
74
|
+
|
|
75
|
+
current_entries.values.select do |entry|
|
|
76
|
+
lastmod_after_cutoff?(entry, cutoff) || entry.lastmod.to_s.strip.empty?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def current_entries
|
|
81
|
+
sitemap_files.each_with_object({}) do |sitemap_file, entries|
|
|
82
|
+
Parser.new(path: sitemap_file).entries.each do |entry|
|
|
83
|
+
next if entry.loc.to_s.strip.empty?
|
|
84
|
+
|
|
85
|
+
entries[entry.loc] = entry
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def since_cutoff
|
|
91
|
+
raw_value = ENV["SINCE"].to_s.strip
|
|
92
|
+
return recent_cutoff if raw_value.empty?
|
|
93
|
+
|
|
94
|
+
Time.iso8601(raw_value).utc
|
|
95
|
+
rescue ArgumentError
|
|
96
|
+
raise ArgumentError, "Invalid SINCE value: #{raw_value.inspect}. Use ISO 8601, e.g. 2026-04-18T10:30:00Z."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def recent_cutoff
|
|
100
|
+
hours = ENV["INDEXNOW_RECENT_HOURS"].to_s.strip
|
|
101
|
+
return if hours.empty?
|
|
102
|
+
|
|
103
|
+
hours_ago = Integer(hours, exception: false)
|
|
104
|
+
unless hours_ago&.positive?
|
|
105
|
+
raise ArgumentError, "Invalid INDEXNOW_RECENT_HOURS value: #{hours.inspect}. Use a positive integer."
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Time.now.utc - (hours_ago * 3600)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def lastmod_after_cutoff?(entry, cutoff)
|
|
112
|
+
lastmod = entry_lastmod(entry)
|
|
113
|
+
return false unless lastmod
|
|
114
|
+
|
|
115
|
+
lastmod >= cutoff
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def entry_lastmod(entry)
|
|
119
|
+
return if entry.lastmod.to_s.strip.empty?
|
|
120
|
+
|
|
121
|
+
Time.iso8601(entry.lastmod.to_s).utc
|
|
122
|
+
rescue ArgumentError
|
|
123
|
+
logger.debug { "IndexNow: skipping invalid sitemap lastmod #{entry.lastmod.inspect} for #{entry.loc}" }
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def max_urls_per_request
|
|
128
|
+
ENV.fetch("INDEXNOW_MAX_URLS_PER_REQUEST", index_now_configuration.max_urls_per_request).to_i
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def submit_batch(api_key:, urls:)
|
|
132
|
+
payload = {host: hostname, key: api_key, urlList: urls}
|
|
133
|
+
response = index_now_connection.post("/indexnow") do |request|
|
|
134
|
+
request.headers["Content-Type"] = "application/json"
|
|
135
|
+
request.body = payload.to_json
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if response.success?
|
|
139
|
+
logger.debug { "Successfully pinged IndexNow with #{urls.count} URLs." }
|
|
140
|
+
true
|
|
141
|
+
else
|
|
142
|
+
logger.debug { "Failed to ping IndexNow. Status: #{response.status}, Body: #{response.body}" }
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def index_now_connection
|
|
148
|
+
@index_now_connection ||= connection || Faraday.new(url: index_now_configuration.endpoint) do |faraday|
|
|
149
|
+
faraday.request :json
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def dry_run?
|
|
154
|
+
ENV["INDEXNOW_DRY_RUN"] == "1" || index_now_configuration.dry_run?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def read_api_key
|
|
158
|
+
configured_key = index_now_configuration.key.to_s.strip
|
|
159
|
+
return configured_key unless configured_key.empty?
|
|
160
|
+
|
|
161
|
+
key_file = configuration.public_path.glob("*.txt").find do |file|
|
|
162
|
+
filename = file.basename(".txt").to_s
|
|
163
|
+
next unless filename.match?(/\A[a-zA-Z0-9-]{8,128}\z/)
|
|
164
|
+
|
|
165
|
+
File.read(file).strip == filename
|
|
166
|
+
end
|
|
167
|
+
return nil unless key_file
|
|
168
|
+
|
|
169
|
+
File.read(key_file).strip
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
data/lib/indexmap/task_runner.rb
CHANGED
|
@@ -11,6 +11,7 @@ module Indexmap
|
|
|
11
11
|
def create
|
|
12
12
|
remove_existing_sitemap_files
|
|
13
13
|
configuration.writer.write
|
|
14
|
+
write_index_now_key
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def format
|
|
@@ -28,6 +29,14 @@ module Indexmap
|
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
def validate
|
|
33
|
+
Validator.new(configuration: configuration).validate!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write_index_now_key
|
|
37
|
+
Pinger::IndexNow.new(configuration: configuration).write_key_file
|
|
38
|
+
end
|
|
39
|
+
|
|
31
40
|
private
|
|
32
41
|
|
|
33
42
|
attr_reader :configuration
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Indexmap
|
|
4
|
+
class Validator
|
|
5
|
+
def initialize(configuration: Indexmap.configuration, path: nil)
|
|
6
|
+
@configuration = configuration
|
|
7
|
+
@path = path
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def validate!
|
|
11
|
+
sitemap_path = path || Indexmap::Path.existing_public_path(
|
|
12
|
+
public_path: configuration.public_path,
|
|
13
|
+
index_filename: configuration.index_filename
|
|
14
|
+
)
|
|
15
|
+
raise ValidationError, "Missing sitemap file: #{sitemap_path}" unless File.exist?(sitemap_path)
|
|
16
|
+
|
|
17
|
+
entries = Parser.new(path: sitemap_path).entries
|
|
18
|
+
validate_duplicates!(entries)
|
|
19
|
+
validate_parameterized_urls!(entries)
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :configuration, :path
|
|
26
|
+
|
|
27
|
+
def validate_duplicates!(entries)
|
|
28
|
+
duplicates = entries.map(&:loc).group_by(&:itself).select { |_url, values| values.size > 1 }.keys
|
|
29
|
+
return if duplicates.empty?
|
|
30
|
+
|
|
31
|
+
raise ValidationError, "Duplicate sitemap URLs detected: #{duplicates.first(5).join(", ")}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_parameterized_urls!(entries)
|
|
35
|
+
param_urls = entries.map(&:loc).select { |url| url&.include?("?") }
|
|
36
|
+
return if param_urls.empty?
|
|
37
|
+
|
|
38
|
+
raise ValidationError, "Parameterized sitemap URLs detected: #{param_urls.first(5).join(", ")}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/indexmap/version.rb
CHANGED
data/lib/indexmap.rb
CHANGED
|
@@ -7,16 +7,25 @@ require "pathname"
|
|
|
7
7
|
require "time"
|
|
8
8
|
|
|
9
9
|
require_relative "indexmap/version"
|
|
10
|
+
require_relative "indexmap/google_configuration"
|
|
11
|
+
require_relative "indexmap/index_now_configuration"
|
|
10
12
|
require_relative "indexmap/configuration"
|
|
11
13
|
require_relative "indexmap/entry"
|
|
14
|
+
require_relative "indexmap/path"
|
|
15
|
+
require_relative "indexmap/parser"
|
|
16
|
+
require_relative "indexmap/pinger/base"
|
|
17
|
+
require_relative "indexmap/pinger/google"
|
|
18
|
+
require_relative "indexmap/pinger/index_now"
|
|
12
19
|
require_relative "indexmap/section"
|
|
13
20
|
require_relative "indexmap/task_runner"
|
|
21
|
+
require_relative "indexmap/validator"
|
|
14
22
|
require_relative "indexmap/writer"
|
|
15
23
|
|
|
16
24
|
module Indexmap
|
|
17
25
|
class Error < StandardError; end
|
|
18
26
|
|
|
19
27
|
class ConfigurationError < Error; end
|
|
28
|
+
class ValidationError < Error; end
|
|
20
29
|
|
|
21
30
|
class << self
|
|
22
31
|
def configuration
|
|
@@ -4,10 +4,42 @@ namespace :sitemap do
|
|
|
4
4
|
runner = Indexmap::TaskRunner.new
|
|
5
5
|
runner.create
|
|
6
6
|
runner.format
|
|
7
|
+
runner.validate
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
desc "Format sitemap files for better readability"
|
|
10
11
|
task format: :environment do
|
|
11
12
|
Indexmap::TaskRunner.new.format
|
|
12
13
|
end
|
|
14
|
+
|
|
15
|
+
desc "Validate sitemap shape and URL hygiene"
|
|
16
|
+
task validate: :environment do
|
|
17
|
+
Indexmap::TaskRunner.new.validate
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "Ping all configured search engines"
|
|
21
|
+
task ping: :environment do
|
|
22
|
+
Rake::Task["sitemap:index_now:ping"].invoke
|
|
23
|
+
Rake::Task["sitemap:google:ping"].invoke
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
namespace :google do
|
|
27
|
+
desc "Ping Google Search Console"
|
|
28
|
+
task ping: :environment do
|
|
29
|
+
Indexmap::Pinger::Google.new.ping
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
namespace :index_now do
|
|
34
|
+
desc "Ping IndexNow. ENV: SINCE=2026-04-18T10:30:00Z or INDEXNOW_RECENT_HOURS=24"
|
|
35
|
+
task ping: :environment do
|
|
36
|
+
Indexmap::Pinger::IndexNow.new.ping
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "Write the IndexNow key file into public/"
|
|
40
|
+
task write_key: :environment do
|
|
41
|
+
path = Indexmap::TaskRunner.new.write_index_now_key
|
|
42
|
+
puts "Wrote #{path}" if path
|
|
43
|
+
end
|
|
44
|
+
end
|
|
13
45
|
end
|
|
@@ -66,4 +66,18 @@ class IndexmapConfigurationTest < Minitest::Test
|
|
|
66
66
|
|
|
67
67
|
assert_equal "Indexmap format must be one of: index, single_file", error.message
|
|
68
68
|
end
|
|
69
|
+
|
|
70
|
+
def test_exposes_nested_google_and_index_now_configuration
|
|
71
|
+
Indexmap.configure do |config|
|
|
72
|
+
config.google.credentials = -> { "{\"type\":\"service_account\"}" }
|
|
73
|
+
config.google.property = -> { "sc-domain:example.com" }
|
|
74
|
+
config.index_now.key = -> { "example-key" }
|
|
75
|
+
config.index_now.max_urls_per_request = -> { 250 }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
assert_equal "{\"type\":\"service_account\"}", Indexmap.configuration.google.credentials
|
|
79
|
+
assert_equal "sc-domain:example.com", Indexmap.configuration.google.property
|
|
80
|
+
assert_equal "example-key", Indexmap.configuration.index_now.key
|
|
81
|
+
assert_equal 250, Indexmap.configuration.index_now.max_urls_per_request
|
|
82
|
+
end
|
|
69
83
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class IndexmapParserTest < Minitest::Test
|
|
6
|
+
def test_parses_remote_sitemap_urlset
|
|
7
|
+
stub_request(:get, "https://www.example.com/sitemap.xml")
|
|
8
|
+
.to_return(
|
|
9
|
+
status: 200,
|
|
10
|
+
body: <<~XML
|
|
11
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
12
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
13
|
+
<url><loc>https://www.example.com/</loc></url>
|
|
14
|
+
<url><loc>https://www.example.com/pages/features</loc></url>
|
|
15
|
+
</urlset>
|
|
16
|
+
XML
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
parser = Indexmap::Parser.new(path: "https://www.example.com/sitemap.xml")
|
|
20
|
+
|
|
21
|
+
assert_equal ["/", "/pages/features"], parser.paths
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_parses_remote_sitemap_index_with_child_sitemap
|
|
25
|
+
stub_request(:get, "https://www.example.com/sitemap.xml")
|
|
26
|
+
.to_return(
|
|
27
|
+
status: 200,
|
|
28
|
+
body: <<~XML
|
|
29
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
30
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
31
|
+
<sitemap><loc>/sitemaps/content.xml</loc></sitemap>
|
|
32
|
+
</sitemapindex>
|
|
33
|
+
XML
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
stub_request(:get, "https://www.example.com/sitemaps/content.xml")
|
|
37
|
+
.to_return(
|
|
38
|
+
status: 200,
|
|
39
|
+
body: <<~XML
|
|
40
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
41
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
42
|
+
<url><loc>https://www.example.com/tools/google-reviews-calculator</loc></url>
|
|
43
|
+
</urlset>
|
|
44
|
+
XML
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
parser = Indexmap::Parser.new(path: "https://www.example.com/sitemap.xml")
|
|
48
|
+
|
|
49
|
+
assert_equal ["/tools/google-reviews-calculator"], parser.paths
|
|
50
|
+
assert_equal ["https://www.reviato.com/tools/google-reviews-calculator"], parser.urls(base_url: "https://www.reviato.com")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_can_rebase_remote_child_sitemap_urls_to_the_fetched_sitemap_origin
|
|
54
|
+
stub_request(:get, "http://localhost:3001/sitemap.xml")
|
|
55
|
+
.to_return(
|
|
56
|
+
status: 200,
|
|
57
|
+
body: <<~XML
|
|
58
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
59
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
60
|
+
<sitemap><loc>https://www.reviato.com/sitemap-marketing.xml</loc></sitemap>
|
|
61
|
+
</sitemapindex>
|
|
62
|
+
XML
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
stub_request(:get, "http://localhost:3001/sitemap-marketing.xml")
|
|
66
|
+
.to_return(
|
|
67
|
+
status: 200,
|
|
68
|
+
body: <<~XML
|
|
69
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
70
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
71
|
+
<url><loc>https://www.reviato.com/pages/pricing</loc></url>
|
|
72
|
+
</urlset>
|
|
73
|
+
XML
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
parser = Indexmap::Parser.new(path: "http://localhost:3001/sitemap.xml", rebase_remote_children: true)
|
|
77
|
+
|
|
78
|
+
assert_equal ["/pages/pricing"], parser.paths
|
|
79
|
+
assert_equal ["http://localhost:3001/pages/pricing"], parser.urls(base_url: "http://localhost:3001")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class IndexmapPathTest < Minitest::Test
|
|
6
|
+
def test_existing_public_path_prefers_sitemap_index_when_present
|
|
7
|
+
Dir.mktmpdir do |dir|
|
|
8
|
+
public_path = Pathname(dir)
|
|
9
|
+
public_path.join("sitemap_index.xml").write("<urlset/>")
|
|
10
|
+
public_path.join("sitemap.xml").write("<sitemapindex/>")
|
|
11
|
+
|
|
12
|
+
assert_equal public_path.join("sitemap.xml"), Indexmap::Path.existing_public_path(public_path: public_path)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_existing_public_path_falls_back_to_legacy_sitemap_path
|
|
17
|
+
Dir.mktmpdir do |dir|
|
|
18
|
+
public_path = Pathname(dir)
|
|
19
|
+
public_path.join("sitemap_index.xml").write("<sitemapindex/>")
|
|
20
|
+
|
|
21
|
+
assert_equal public_path.join("sitemap_index.xml"), Indexmap::Path.existing_public_path(public_path: public_path)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_canonical_url_targets_sitemap_index
|
|
26
|
+
assert_equal "https://www.example.com/sitemap.xml", Indexmap::Path.canonical_url("https://www.example.com")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class IndexmapPingerGoogleTest < Minitest::Test
|
|
6
|
+
SiteEntry = Struct.new(:site_url)
|
|
7
|
+
SiteList = Struct.new(:site_entry)
|
|
8
|
+
|
|
9
|
+
class FakeWebmastersService
|
|
10
|
+
attr_accessor :authorization
|
|
11
|
+
attr_reader :submitted
|
|
12
|
+
|
|
13
|
+
def initialize(site_urls:)
|
|
14
|
+
@site_urls = site_urls
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def list_sites
|
|
18
|
+
SiteList.new(@site_urls.map { |site_url| SiteEntry.new(site_url) })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def submit_sitemap(property, sitemap_url)
|
|
22
|
+
@submitted = [property, sitemap_url]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_pings_google_for_each_sitemap_file
|
|
27
|
+
Dir.mktmpdir do |dir|
|
|
28
|
+
public_path = Pathname(dir)
|
|
29
|
+
public_path.join("sitemap.xml").write("<sitemapindex/>")
|
|
30
|
+
|
|
31
|
+
configuration = Indexmap::Configuration.new
|
|
32
|
+
configuration.base_url = "https://www.example.com"
|
|
33
|
+
configuration.public_path = public_path
|
|
34
|
+
configuration.google.credentials = "{\"type\":\"service_account\"}"
|
|
35
|
+
|
|
36
|
+
service = FakeWebmastersService.new(site_urls: ["sc-domain:example.com"])
|
|
37
|
+
builder_calls = []
|
|
38
|
+
credentials_builder = lambda do |credentials:, scope:|
|
|
39
|
+
builder_calls << [credentials, scope]
|
|
40
|
+
:fake_authorizer
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Indexmap::Pinger::Google.new(
|
|
44
|
+
configuration: configuration,
|
|
45
|
+
service: service,
|
|
46
|
+
credentials_builder: credentials_builder
|
|
47
|
+
).ping
|
|
48
|
+
|
|
49
|
+
assert_equal [["{\"type\":\"service_account\"}", "https://www.googleapis.com/auth/webmasters"]], builder_calls
|
|
50
|
+
assert_equal :fake_authorizer, service.authorization
|
|
51
|
+
assert_equal ["sc-domain:example.com", "https://www.example.com/sitemap.xml"], service.submitted
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_skips_google_ping_when_credentials_are_missing
|
|
56
|
+
Dir.mktmpdir do |dir|
|
|
57
|
+
public_path = Pathname(dir)
|
|
58
|
+
public_path.join("sitemap.xml").write("<sitemapindex/>")
|
|
59
|
+
|
|
60
|
+
configuration = Indexmap::Configuration.new
|
|
61
|
+
configuration.base_url = "https://www.example.com"
|
|
62
|
+
configuration.public_path = public_path
|
|
63
|
+
|
|
64
|
+
service = FakeWebmastersService.new(site_urls: ["sc-domain:example.com"])
|
|
65
|
+
|
|
66
|
+
Indexmap::Pinger::Google.new(configuration: configuration, service: service).ping
|
|
67
|
+
|
|
68
|
+
assert_nil service.submitted
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class IndexmapPingerIndexNowTest < Minitest::Test
|
|
6
|
+
def test_writes_key_file_from_configuration
|
|
7
|
+
Dir.mktmpdir do |dir|
|
|
8
|
+
configuration = Indexmap::Configuration.new
|
|
9
|
+
configuration.base_url = "https://www.example.com"
|
|
10
|
+
configuration.public_path = Pathname(dir)
|
|
11
|
+
configuration.index_now.key = "test-key"
|
|
12
|
+
|
|
13
|
+
path = Indexmap::Pinger::IndexNow.new(configuration: configuration).write_key_file
|
|
14
|
+
|
|
15
|
+
assert_equal Pathname(dir).join("test-key.txt"), path
|
|
16
|
+
assert_equal "test-key\n", path.read
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_pings_all_sitemap_urls_when_no_cutoff_is_provided
|
|
21
|
+
Dir.mktmpdir do |dir|
|
|
22
|
+
public_path = Pathname(dir)
|
|
23
|
+
write_sitemap_files(
|
|
24
|
+
public_path,
|
|
25
|
+
marketing_lastmod: "2026-04-18T00:00:00Z",
|
|
26
|
+
insights_lastmod: "2026-04-10T00:00:00Z"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
configuration = Indexmap::Configuration.new
|
|
30
|
+
configuration.base_url = "https://www.example.com"
|
|
31
|
+
configuration.public_path = public_path
|
|
32
|
+
configuration.index_now.key = "test-key"
|
|
33
|
+
|
|
34
|
+
indexnow_url = "https://api.indexnow.org/indexnow"
|
|
35
|
+
stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
|
|
36
|
+
|
|
37
|
+
Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
38
|
+
|
|
39
|
+
assert_requested(:post, indexnow_url, times: 1) do |request|
|
|
40
|
+
payload = JSON.parse(request.body)
|
|
41
|
+
assert_equal [
|
|
42
|
+
"https://www.example.com/pages/features",
|
|
43
|
+
"https://www.example.com/insights/us/restaurants/overview"
|
|
44
|
+
].sort, payload.fetch("urlList").sort
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_pings_only_sitemap_urls_newer_than_since
|
|
50
|
+
Dir.mktmpdir do |dir|
|
|
51
|
+
public_path = Pathname(dir)
|
|
52
|
+
write_sitemap_files(
|
|
53
|
+
public_path,
|
|
54
|
+
marketing_lastmod: "2026-04-18T00:00:00Z",
|
|
55
|
+
insights_lastmod: "2026-04-10T00:00:00Z"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
configuration = Indexmap::Configuration.new
|
|
59
|
+
configuration.base_url = "https://www.example.com"
|
|
60
|
+
configuration.public_path = public_path
|
|
61
|
+
configuration.index_now.key = "test-key"
|
|
62
|
+
|
|
63
|
+
indexnow_url = "https://api.indexnow.org/indexnow"
|
|
64
|
+
stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
|
|
65
|
+
|
|
66
|
+
with_env("SINCE" => "2026-04-15T00:00:00Z") do
|
|
67
|
+
Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
assert_requested(:post, indexnow_url, times: 1) do |request|
|
|
71
|
+
payload = JSON.parse(request.body)
|
|
72
|
+
assert_equal ["https://www.example.com/pages/features"], payload.fetch("urlList")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def with_env(overrides)
|
|
80
|
+
previous_values = overrides.to_h { |key, _value| [key, ENV[key]] }
|
|
81
|
+
overrides.each { |key, value| ENV[key] = value }
|
|
82
|
+
yield
|
|
83
|
+
ensure
|
|
84
|
+
previous_values.each do |key, value|
|
|
85
|
+
value.nil? ? ENV.delete(key) : ENV[key] = value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def write_sitemap_files(public_path, marketing_lastmod:, insights_lastmod:)
|
|
90
|
+
public_path.join("sitemap.xml").write(<<~XML)
|
|
91
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
92
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
93
|
+
<sitemap><loc>https://www.example.com/sitemap-marketing.xml</loc></sitemap>
|
|
94
|
+
<sitemap><loc>https://www.example.com/sitemap-insights.xml</loc></sitemap>
|
|
95
|
+
</sitemapindex>
|
|
96
|
+
XML
|
|
97
|
+
|
|
98
|
+
public_path.join("sitemap-marketing.xml").write(<<~XML)
|
|
99
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
100
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
101
|
+
<url>
|
|
102
|
+
<loc>https://www.example.com/pages/features</loc>
|
|
103
|
+
<lastmod>#{marketing_lastmod}</lastmod>
|
|
104
|
+
</url>
|
|
105
|
+
</urlset>
|
|
106
|
+
XML
|
|
107
|
+
|
|
108
|
+
public_path.join("sitemap-insights.xml").write(<<~XML)
|
|
109
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
110
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
111
|
+
<url>
|
|
112
|
+
<loc>https://www.example.com/insights/us/restaurants/overview</loc>
|
|
113
|
+
<lastmod>#{insights_lastmod}</lastmod>
|
|
114
|
+
</url>
|
|
115
|
+
</urlset>
|
|
116
|
+
XML
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class IndexmapTaskRunnerTest < Minitest::Test
|
|
6
|
+
def test_create_removes_existing_sitemap_files_writes_new_sitemap_and_key_file
|
|
7
|
+
Dir.mktmpdir do |dir|
|
|
8
|
+
public_path = Pathname(dir)
|
|
9
|
+
public_path.join("sitemap.xml").write("old")
|
|
10
|
+
public_path.join("sitemap-pages.xml.gz").write("old")
|
|
11
|
+
|
|
12
|
+
configuration = Indexmap::Configuration.new
|
|
13
|
+
configuration.base_url = "https://example.com"
|
|
14
|
+
configuration.public_path = public_path
|
|
15
|
+
configuration.sections = [
|
|
16
|
+
Indexmap::Section.new(
|
|
17
|
+
filename: "sitemap-pages.xml",
|
|
18
|
+
entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
|
|
19
|
+
)
|
|
20
|
+
]
|
|
21
|
+
configuration.index_now.key = "test-key"
|
|
22
|
+
|
|
23
|
+
Indexmap::TaskRunner.new(configuration: configuration).create
|
|
24
|
+
|
|
25
|
+
assert_equal false, public_path.join("sitemap-pages.xml.gz").exist?
|
|
26
|
+
assert_includes public_path.join("sitemap.xml").read, "<sitemapindex"
|
|
27
|
+
assert_equal "test-key\n", public_path.join("test-key.txt").read
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_write_index_now_key_returns_nil_when_key_is_not_configured
|
|
32
|
+
Dir.mktmpdir do |dir|
|
|
33
|
+
configuration = Indexmap::Configuration.new
|
|
34
|
+
configuration.base_url = "https://example.com"
|
|
35
|
+
configuration.public_path = Pathname(dir)
|
|
36
|
+
configuration.sections = [
|
|
37
|
+
Indexmap::Section.new(
|
|
38
|
+
filename: "sitemap-pages.xml",
|
|
39
|
+
entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
|
|
40
|
+
)
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
result = Indexmap::TaskRunner.new(configuration: configuration).write_index_now_key
|
|
44
|
+
|
|
45
|
+
assert_nil result
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|