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
data/lib/indexmap/validator.rb
CHANGED
|
@@ -4,85 +4,202 @@ require "nokogiri"
|
|
|
4
4
|
require "date"
|
|
5
5
|
require "time"
|
|
6
6
|
require "uri"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require "stringio"
|
|
9
|
+
require "json"
|
|
7
10
|
|
|
8
11
|
module Indexmap
|
|
9
12
|
class Validator
|
|
10
|
-
|
|
13
|
+
MAX_ENTRIES = 50_000
|
|
14
|
+
MAX_BYTES = 52_428_800
|
|
15
|
+
|
|
16
|
+
attr_reader :entries
|
|
17
|
+
|
|
18
|
+
def initialize(configuration: Indexmap.configuration, filename: nil, storage: nil)
|
|
11
19
|
@configuration = configuration
|
|
20
|
+
@storage = storage
|
|
12
21
|
@filename = filename
|
|
13
22
|
end
|
|
14
23
|
|
|
15
|
-
def validate!
|
|
24
|
+
def validate!(collect: true)
|
|
16
25
|
sitemap_filename = filename || configuration.index_filename
|
|
17
26
|
raise ValidationError, "Missing sitemap file: #{sitemap_filename}" unless storage.exist?(sitemap_filename)
|
|
18
27
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
@entries = []
|
|
29
|
+
count = 0
|
|
30
|
+
Dir.mktmpdir("indexmap-validation") do |directory|
|
|
31
|
+
duplicates = Duplicates.new(directory: directory)
|
|
32
|
+
validate_sitemap_files!(sitemap_filename) do |entry|
|
|
33
|
+
validate_parameterized_urls!([entry])
|
|
34
|
+
validate_fragment_urls!([entry])
|
|
35
|
+
validate_absolute_http_urls!([entry])
|
|
36
|
+
validate_same_host_urls!([entry])
|
|
37
|
+
validate_lastmods!([entry])
|
|
38
|
+
yield entry if block_given?
|
|
39
|
+
duplicates.add(entry.loc)
|
|
40
|
+
@entries << entry if collect
|
|
41
|
+
count += 1
|
|
42
|
+
end
|
|
43
|
+
duplicates.validate!
|
|
44
|
+
end
|
|
45
|
+
raise ValidationError, "Sitemap has no URLs" if count == 0
|
|
46
|
+
|
|
28
47
|
true
|
|
29
48
|
end
|
|
30
49
|
|
|
50
|
+
def each_entry
|
|
51
|
+
return enum_for(:each_entry) unless block_given?
|
|
52
|
+
|
|
53
|
+
Dir.mktmpdir("indexmap-entries") do |directory|
|
|
54
|
+
path = File.join(directory, "entries.jsonl")
|
|
55
|
+
File.open(path, "wb") do |file|
|
|
56
|
+
validate!(collect: false) { |entry| file.puts(JSON.generate(entry.to_h)) }
|
|
57
|
+
end
|
|
58
|
+
File.foreach(path) do |line|
|
|
59
|
+
yield Parser::Entry.new(**JSON.parse(line, symbolize_names: true))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
31
64
|
private
|
|
32
65
|
|
|
33
66
|
attr_reader :configuration, :filename
|
|
34
67
|
|
|
35
68
|
def storage
|
|
36
|
-
configuration.storage
|
|
69
|
+
@storage ||= configuration.storage
|
|
37
70
|
end
|
|
38
71
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
raise ValidationError, "
|
|
72
|
+
def validate_sitemap_files!(sitemap_filename)
|
|
73
|
+
pending = [[sitemap_filename, false]]
|
|
74
|
+
active = Set.new
|
|
75
|
+
visited = Set.new
|
|
76
|
+
until pending.empty?
|
|
77
|
+
name, leaving = pending.pop
|
|
78
|
+
if leaving
|
|
79
|
+
active.delete(name)
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
raise ValidationError, "Sitemap cycle detected: #{name}" if active.include?(name)
|
|
83
|
+
next if visited.include?(name)
|
|
84
|
+
|
|
85
|
+
active.add(name)
|
|
86
|
+
visited.add(name)
|
|
87
|
+
pending << [name, true]
|
|
88
|
+
children = read_sitemap(name) { |entry| yield entry }
|
|
89
|
+
children.reverse_each { |child| pending << [child, false] }
|
|
50
90
|
end
|
|
51
91
|
end
|
|
52
92
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
93
|
+
def read_sitemap(filename)
|
|
94
|
+
children = []
|
|
95
|
+
root = nil
|
|
96
|
+
count = 0
|
|
97
|
+
open_sitemap(filename) do |io|
|
|
98
|
+
reader = Nokogiri::XML::Reader.from_io(io, nil, nil, Nokogiri::XML::ParseOptions::NONET)
|
|
99
|
+
reader.each do |node|
|
|
100
|
+
if node.node_type == Nokogiri::XML::Reader::TYPE_DOCUMENT_TYPE
|
|
101
|
+
raise ValidationError, "Sitemap DTDs are not allowed: #{filename}"
|
|
102
|
+
end
|
|
103
|
+
next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
104
|
+
|
|
105
|
+
if node.depth == 0
|
|
106
|
+
root = node.local_name
|
|
107
|
+
unless %w[urlset sitemapindex].include?(root)
|
|
108
|
+
raise ValidationError, "Invalid sitemap root element in #{filename}: #{root}"
|
|
109
|
+
end
|
|
110
|
+
unless node.namespace_uri == "http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
111
|
+
raise ValidationError, "Invalid sitemap namespace: #{filename}"
|
|
112
|
+
end
|
|
113
|
+
elsif node.depth == 1 && node.local_name == ((root == "urlset") ? "url" : "sitemap")
|
|
114
|
+
count += 1
|
|
115
|
+
raise ValidationError, "Sitemap exceeds 50,000 entries: #{filename}" if count > MAX_ENTRIES
|
|
116
|
+
|
|
117
|
+
fields = read_fields(reader)
|
|
118
|
+
location = location_for(fields, filename: filename)
|
|
119
|
+
if root == "urlset"
|
|
120
|
+
lastmod = fields.fetch("lastmod").to_s.strip
|
|
121
|
+
yield Parser::Entry.new(loc: location, lastmod: lastmod.empty? ? nil : lastmod, source_sitemap: filename)
|
|
122
|
+
else
|
|
123
|
+
child = local_child_filename(location, parent_filename: filename)
|
|
124
|
+
children << child
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
if count == 0
|
|
130
|
+
message = (root == "sitemapindex") ? "Sitemap index has no child sitemap URLs" : "Sitemap has no URLs"
|
|
131
|
+
raise ValidationError, "#{message}: #{filename}"
|
|
132
|
+
end
|
|
133
|
+
children.each { |child| raise ValidationError, "Missing child sitemap file: #{child}" unless storage.exist?(child) }
|
|
134
|
+
if children.uniq.size != children.size
|
|
135
|
+
raise ValidationError, "Duplicate child sitemap URLs detected: #{filename}"
|
|
136
|
+
end
|
|
137
|
+
children
|
|
57
138
|
rescue Nokogiri::XML::SyntaxError => error
|
|
58
139
|
raise ValidationError, "Invalid sitemap XML in #{filename}: #{error.message.lines.first.strip}"
|
|
59
140
|
end
|
|
60
141
|
|
|
61
|
-
def
|
|
62
|
-
|
|
142
|
+
def open_sitemap(filename, &block)
|
|
143
|
+
if storage.respond_to?(:open) && storage.respond_to?(:metadata)
|
|
144
|
+
if storage.metadata(filename).fetch(:bytes) > MAX_BYTES
|
|
145
|
+
raise ValidationError, "Sitemap exceeds 52,428,800 bytes: #{filename}"
|
|
146
|
+
end
|
|
147
|
+
storage.open(filename, &block)
|
|
148
|
+
else
|
|
149
|
+
body = storage.read(filename)
|
|
150
|
+
raise ValidationError, "Sitemap exceeds 52,428,800 bytes: #{filename}" if body.bytesize > MAX_BYTES
|
|
63
151
|
|
|
64
|
-
|
|
152
|
+
yield StringIO.new(body)
|
|
153
|
+
end
|
|
65
154
|
end
|
|
66
155
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
156
|
+
def read_fields(reader)
|
|
157
|
+
fields = {"loc" => nil, "lastmod" => nil}
|
|
158
|
+
return fields if reader.self_closing?
|
|
159
|
+
|
|
160
|
+
text = nil
|
|
161
|
+
while reader.read
|
|
162
|
+
type = reader.node_type
|
|
163
|
+
if type == Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
164
|
+
break if reader.depth == 1
|
|
165
|
+
|
|
166
|
+
text = nil if reader.depth == 2
|
|
167
|
+
elsif type == Nokogiri::XML::Reader::TYPE_ELEMENT && reader.depth == 2
|
|
168
|
+
name = reader.local_name
|
|
169
|
+
if fields.key?(name)
|
|
170
|
+
raise ValidationError, "Repeated sitemap field: #{name}" if fields[name]
|
|
171
|
+
|
|
172
|
+
fields[name] = +""
|
|
173
|
+
text = reader.self_closing? ? nil : fields[name]
|
|
174
|
+
else
|
|
175
|
+
text = nil
|
|
176
|
+
end
|
|
177
|
+
elsif text && [Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_CDATA,
|
|
178
|
+
Nokogiri::XML::Reader::TYPE_WHITESPACE, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE].include?(type)
|
|
179
|
+
text << reader.value
|
|
180
|
+
end
|
|
74
181
|
end
|
|
182
|
+
fields
|
|
183
|
+
end
|
|
75
184
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
validate_sitemap_file!(child_filename)
|
|
185
|
+
def location_for(fields, filename:)
|
|
186
|
+
location = fields.fetch("loc").to_s.strip
|
|
187
|
+
if location.empty?
|
|
188
|
+
raise ValidationError, "Sitemap entry must have one nonempty loc: #{filename}"
|
|
81
189
|
end
|
|
190
|
+
raise ValidationError, "Sitemap loc must be shorter than 2,048 characters: #{filename}" if location.length >= 2048
|
|
191
|
+
|
|
192
|
+
location
|
|
82
193
|
end
|
|
83
194
|
|
|
84
195
|
def local_child_filename(location, parent_filename:)
|
|
85
196
|
uri = URI.parse(location)
|
|
197
|
+
base_uri = URI.parse(configuration.base_url)
|
|
198
|
+
if uri.query || uri.fragment || uri.userinfo || (uri.host &&
|
|
199
|
+
[uri.scheme, uri.host, uri.port] != [base_uri.scheme, base_uri.host, base_uri.port]) ||
|
|
200
|
+
(uri.scheme && !%w[http https].include?(uri.scheme))
|
|
201
|
+
raise ValidationError, "Invalid child sitemap URL: #{location}"
|
|
202
|
+
end
|
|
86
203
|
if uri.absolute? || location.start_with?("/")
|
|
87
204
|
normalize_local_filename(uri.path)
|
|
88
205
|
else
|
|
@@ -90,29 +207,18 @@ module Indexmap
|
|
|
90
207
|
normalize_local_filename((parent_directory == ".") ? location : File.join(parent_directory, location))
|
|
91
208
|
end
|
|
92
209
|
rescue URI::InvalidURIError
|
|
93
|
-
|
|
210
|
+
raise ValidationError, "Invalid child sitemap URL: #{location}"
|
|
94
211
|
end
|
|
95
212
|
|
|
96
213
|
def normalize_local_filename(filename)
|
|
97
214
|
normalized = Pathname(filename.to_s).cleanpath.to_s.sub(%r{\A/+}, "")
|
|
98
|
-
|
|
215
|
+
if normalized.empty? || normalized == ".." || normalized.start_with?("../")
|
|
216
|
+
raise ValidationError, "Invalid child sitemap filename: #{filename}"
|
|
217
|
+
end
|
|
99
218
|
|
|
100
219
|
normalized
|
|
101
220
|
end
|
|
102
221
|
|
|
103
|
-
def validate_presence!(entries)
|
|
104
|
-
return unless entries.empty?
|
|
105
|
-
|
|
106
|
-
raise ValidationError, "Sitemap has no URLs"
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def validate_duplicates!(entries)
|
|
110
|
-
duplicates = entries.map(&:loc).group_by(&:itself).select { |_url, values| values.size > 1 }.keys
|
|
111
|
-
return if duplicates.empty?
|
|
112
|
-
|
|
113
|
-
raise ValidationError, "Duplicate sitemap URLs detected: #{duplicates.first(5).join(", ")}"
|
|
114
|
-
end
|
|
115
|
-
|
|
116
222
|
def validate_parameterized_urls!(entries)
|
|
117
223
|
param_urls = entries.map(&:loc).select { |url| url&.include?("?") }
|
|
118
224
|
return if param_urls.empty?
|
|
@@ -130,7 +236,7 @@ module Indexmap
|
|
|
130
236
|
def validate_absolute_http_urls!(entries)
|
|
131
237
|
invalid_urls = entries.map(&:loc).reject do |url|
|
|
132
238
|
uri = parse_uri(url)
|
|
133
|
-
uri&.absolute? && %w[http https].include?(uri.scheme)
|
|
239
|
+
uri&.absolute? && %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
|
|
134
240
|
end
|
|
135
241
|
return if invalid_urls.empty?
|
|
136
242
|
|
|
@@ -154,7 +260,11 @@ module Indexmap
|
|
|
154
260
|
invalid_entries = entries.select do |entry|
|
|
155
261
|
next false if entry.lastmod.nil?
|
|
156
262
|
|
|
157
|
-
|
|
263
|
+
if entry.lastmod.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
264
|
+
Date.iso8601(entry.lastmod)
|
|
265
|
+
else
|
|
266
|
+
Time.iso8601(entry.lastmod)
|
|
267
|
+
end
|
|
158
268
|
false
|
|
159
269
|
rescue ArgumentError
|
|
160
270
|
true
|
|
@@ -171,3 +281,5 @@ module Indexmap
|
|
|
171
281
|
end
|
|
172
282
|
end
|
|
173
283
|
end
|
|
284
|
+
|
|
285
|
+
require_relative "validator/duplicates"
|
data/lib/indexmap/version.rb
CHANGED
data/lib/indexmap/writer.rb
CHANGED
|
@@ -2,34 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
module Indexmap
|
|
4
4
|
class Writer
|
|
5
|
+
attr_reader :counts
|
|
6
|
+
|
|
5
7
|
VALID_FORMATS = %i[index single_file].freeze
|
|
6
8
|
|
|
7
|
-
def initialize(base_url:, sections: nil, entries: nil, index_filename: "sitemap.xml", format: :index)
|
|
8
|
-
@entries =
|
|
9
|
+
def initialize(base_url:, sections: nil, entries: nil, index_filename: "sitemap.xml", format: :index, max_urls: 50_000, max_bytes: 52_428_800, generation: nil)
|
|
10
|
+
@entries = entries || []
|
|
9
11
|
@format = normalize_format(format)
|
|
10
12
|
@sections = normalize_sections(sections)
|
|
11
13
|
@base_url = base_url
|
|
12
14
|
@index_filename = index_filename
|
|
15
|
+
@max_urls = Integer(max_urls.to_s, 10)
|
|
16
|
+
@max_bytes = Integer(max_bytes.to_s, 10)
|
|
17
|
+
@generation = generation
|
|
18
|
+
@counts = {}
|
|
19
|
+
unless (1..Validator::MAX_ENTRIES).cover?(@max_urls) && (1..Validator::MAX_BYTES).cover?(@max_bytes)
|
|
20
|
+
raise ConfigurationError, "Invalid sitemap partition limits"
|
|
21
|
+
end
|
|
13
22
|
end
|
|
14
23
|
|
|
15
24
|
def write
|
|
16
|
-
|
|
25
|
+
each_file.to_a
|
|
26
|
+
end
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
end
|
|
28
|
+
def each_file
|
|
29
|
+
return enum_for(:each_file) unless block_given?
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
if single_file?
|
|
32
|
+
body, = urlset_xml(entries)
|
|
33
|
+
else
|
|
34
|
+
index_entries = []
|
|
35
|
+
sections.each_with_index do |section, section_index|
|
|
36
|
+
part = 0
|
|
37
|
+
chunks(section.entries) do |body, lastmod, count|
|
|
38
|
+
part += 1
|
|
39
|
+
filename = if @generation
|
|
40
|
+
Publication.new(root: index_filename).filename(generation: @generation, section: section_index + 1, part: part)
|
|
41
|
+
elsif part == 1
|
|
42
|
+
section.filename
|
|
43
|
+
else
|
|
44
|
+
section.filename.sub(/\.xml\z/, "-#{Kernel.format("%03d", part)}.xml")
|
|
45
|
+
end
|
|
46
|
+
raise ConfigurationError, "Section filenames must end in .xml" if part > 1 && filename == section.filename
|
|
47
|
+
|
|
48
|
+
@counts[filename] = count
|
|
49
|
+
index_entries << [filename, lastmod]
|
|
50
|
+
raise ValidationError, "Sitemap exceeds 50,000 entries: #{index_filename}" if index_entries.size > Validator::MAX_ENTRIES
|
|
51
|
+
|
|
52
|
+
yield write_file(filename, body)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
body = index_xml(index_entries)
|
|
56
|
+
raise ValidationError, "Sitemap exceeds 52,428,800 bytes: #{index_filename}" if body.bytesize > @max_bytes
|
|
57
|
+
|
|
58
|
+
@counts[index_filename] = index_entries.size
|
|
59
|
+
end
|
|
60
|
+
yield write_file(index_filename, body)
|
|
23
61
|
end
|
|
24
62
|
|
|
25
63
|
private
|
|
26
64
|
|
|
27
65
|
attr_reader :base_url, :entries, :format, :index_filename, :sections
|
|
28
66
|
|
|
29
|
-
def normalize_entries(raw_entries)
|
|
30
|
-
Array(raw_entries).map { |entry| normalize_entry(entry) }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
67
|
def normalize_format(value)
|
|
34
68
|
normalized = value.nil? ? :index : value.to_sym
|
|
35
69
|
return normalized if VALID_FORMATS.include?(normalized)
|
|
@@ -53,25 +87,52 @@ module Indexmap
|
|
|
53
87
|
end
|
|
54
88
|
|
|
55
89
|
def write_file(filename, body)
|
|
56
|
-
|
|
90
|
+
Artifact.new(filename: filename, body: body, content_type: "application/xml")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
HEADER = %(<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n)
|
|
94
|
+
FOOTER = "</urlset>\n"
|
|
95
|
+
|
|
96
|
+
def chunks(source)
|
|
97
|
+
body = +HEADER
|
|
98
|
+
latest = nil
|
|
99
|
+
count = 0
|
|
100
|
+
source.each do |value|
|
|
101
|
+
entry = normalize_entry(value)
|
|
102
|
+
xml = " <url>\n <loc>#{escape(entry.loc)}</loc>\n"
|
|
103
|
+
timestamp = nil
|
|
104
|
+
if entry.lastmod
|
|
105
|
+
date = parsed_lastmod(entry.lastmod)
|
|
106
|
+
xml << " <lastmod>#{escape(date.iso8601)}</lastmod>\n"
|
|
107
|
+
timestamp = comparable_lastmod(date)
|
|
108
|
+
end
|
|
109
|
+
xml << " </url>\n"
|
|
110
|
+
if HEADER.bytesize + xml.bytesize + FOOTER.bytesize > @max_bytes
|
|
111
|
+
raise ValidationError, "Sitemap entry exceeds byte limit"
|
|
112
|
+
end
|
|
113
|
+
if count > 0 && (count == @max_urls || body.bytesize + xml.bytesize + FOOTER.bytesize > @max_bytes)
|
|
114
|
+
yield body << FOOTER, latest, count
|
|
115
|
+
body = +HEADER
|
|
116
|
+
latest = nil
|
|
117
|
+
count = 0
|
|
118
|
+
end
|
|
119
|
+
body << xml
|
|
120
|
+
latest = timestamp if timestamp && (latest.nil? || timestamp > latest)
|
|
121
|
+
count += 1
|
|
122
|
+
end
|
|
123
|
+
yield body << FOOTER, latest, count if count > 0
|
|
57
124
|
end
|
|
58
125
|
|
|
59
|
-
def urlset_xml(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
]
|
|
126
|
+
def urlset_xml(source)
|
|
127
|
+
result = nil
|
|
128
|
+
chunks(source) do |body, lastmod, count|
|
|
129
|
+
raise ValidationError, "Single-file sitemap exceeds partition limits; use index format" if result
|
|
64
130
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
lines << " <url>"
|
|
68
|
-
lines << " <loc>#{escape(sitemap_entry.loc)}</loc>"
|
|
69
|
-
lines << " <lastmod>#{format_lastmod(sitemap_entry.lastmod)}</lastmod>" if sitemap_entry.lastmod
|
|
70
|
-
lines << " </url>"
|
|
131
|
+
@counts[index_filename] = count
|
|
132
|
+
result = [body, lastmod]
|
|
71
133
|
end
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
lines.join("\n") + "\n"
|
|
134
|
+
@counts[index_filename] ||= 0
|
|
135
|
+
result || [HEADER + FOOTER, nil]
|
|
75
136
|
end
|
|
76
137
|
|
|
77
138
|
def index_xml(sitemap_sections)
|
|
@@ -80,10 +141,9 @@ module Indexmap
|
|
|
80
141
|
%(<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)
|
|
81
142
|
]
|
|
82
143
|
|
|
83
|
-
sitemap_sections.each do |
|
|
144
|
+
sitemap_sections.each do |filename, lastmod|
|
|
84
145
|
lines << " <sitemap>"
|
|
85
|
-
lines << " <loc>#{escape(index_loc(
|
|
86
|
-
lastmod = section_lastmod(section)
|
|
146
|
+
lines << " <loc>#{escape(index_loc(filename))}</loc>"
|
|
87
147
|
lines << " <lastmod>#{format_lastmod(lastmod)}</lastmod>" if lastmod
|
|
88
148
|
lines << " </sitemap>"
|
|
89
149
|
end
|
|
@@ -102,11 +162,6 @@ module Indexmap
|
|
|
102
162
|
File.join(base_url.sub(%r{/\z}, ""), filename)
|
|
103
163
|
end
|
|
104
164
|
|
|
105
|
-
def section_lastmod(section)
|
|
106
|
-
timestamps = Array(section.entries).map { |entry| comparable_lastmod(normalize_entry(entry).lastmod) }.compact
|
|
107
|
-
timestamps.max
|
|
108
|
-
end
|
|
109
|
-
|
|
110
165
|
def format_lastmod(value)
|
|
111
166
|
timestamp = parsed_lastmod(value)
|
|
112
167
|
|
data/lib/indexmap.rb
CHANGED
|
@@ -8,11 +8,13 @@ require "time"
|
|
|
8
8
|
require_relative "indexmap/version"
|
|
9
9
|
require_relative "indexmap/google_configuration"
|
|
10
10
|
require_relative "indexmap/index_now_configuration"
|
|
11
|
-
require_relative "indexmap/
|
|
11
|
+
require_relative "indexmap/robots"
|
|
12
|
+
require_relative "indexmap/artifact"
|
|
12
13
|
require_relative "indexmap/storage/memory"
|
|
13
14
|
require_relative "indexmap/storage/filesystem"
|
|
14
15
|
require_relative "indexmap/storage/active_storage"
|
|
15
16
|
require_relative "indexmap/configuration"
|
|
17
|
+
require_relative "indexmap/publication"
|
|
16
18
|
require_relative "indexmap/creator"
|
|
17
19
|
require_relative "indexmap/entry"
|
|
18
20
|
require_relative "indexmap/output"
|
|
@@ -33,6 +35,17 @@ module Indexmap
|
|
|
33
35
|
|
|
34
36
|
class ConfigurationError < Error; end
|
|
35
37
|
class ValidationError < Error; end
|
|
38
|
+
class SupersededPublication < Error; end
|
|
39
|
+
|
|
40
|
+
class PublicationCleanupError < Error
|
|
41
|
+
attr_reader :failures
|
|
42
|
+
|
|
43
|
+
def initialize(failures)
|
|
44
|
+
@failures = failures
|
|
45
|
+
artifacts = failures.map { |failure| failure.fetch(:artifact) }.join(", ")
|
|
46
|
+
super("Failed to clean up publication artifacts: #{artifacts}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
36
49
|
|
|
37
50
|
class << self
|
|
38
51
|
def configuration
|
|
@@ -5,7 +5,7 @@ namespace :indexmap do
|
|
|
5
5
|
runner = Indexmap::TaskRunner.new
|
|
6
6
|
create_result = runner.create
|
|
7
7
|
|
|
8
|
-
puts "Created
|
|
8
|
+
puts "Created and validated #{file_count(create_result[:files])}."
|
|
9
9
|
puts "Files created:"
|
|
10
10
|
create_result[:sitemaps].each do |sitemap|
|
|
11
11
|
puts " - #{sitemap[:location]} (#{link_count(sitemap[:link_count])})"
|
|
@@ -13,6 +13,13 @@ namespace :indexmap do
|
|
|
13
13
|
puts "IndexNow key file: #{create_result[:index_now_key_filename]}" if create_result[:index_now_key_filename]
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
desc "Prune expired immutable generations (RETENTION_HOURS defaults to 48)"
|
|
17
|
+
task prune: :environment do
|
|
18
|
+
hours = Integer(ENV.fetch("RETENTION_HOURS", "48"), 10)
|
|
19
|
+
deleted = Indexmap::TaskRunner.new.prune(retention: hours * 3600)
|
|
20
|
+
puts "Pruned #{deleted.size} sitemap artifacts."
|
|
21
|
+
end
|
|
22
|
+
|
|
16
23
|
desc "Format sitemap files for better readability"
|
|
17
24
|
task format: :environment do
|
|
18
25
|
runner = Indexmap::TaskRunner.new
|
|
@@ -56,9 +63,21 @@ namespace :indexmap do
|
|
|
56
63
|
end
|
|
57
64
|
|
|
58
65
|
namespace :index_now do
|
|
66
|
+
desc "Preview exact IndexNow selection as JSON Lines without submitting"
|
|
67
|
+
task plan: :environment do
|
|
68
|
+
Indexmap::Pinger::IndexNow.new.plan.each { |row| puts JSON.generate(row) }
|
|
69
|
+
end
|
|
70
|
+
|
|
59
71
|
desc "Ping IndexNow. ENV: SINCE=2026-04-18T10:30:00Z or INDEXNOW_RECENT_HOURS=24"
|
|
60
72
|
task ping: :environment do
|
|
61
|
-
|
|
73
|
+
report = ENV["INDEXNOW_REPORT"] == "1"
|
|
74
|
+
result = Indexmap::Pinger::IndexNow.new.ping do |event|
|
|
75
|
+
puts JSON.generate(event) if report
|
|
76
|
+
end
|
|
77
|
+
if report
|
|
78
|
+
puts JSON.generate(result.merge(event: :summary))
|
|
79
|
+
next
|
|
80
|
+
end
|
|
62
81
|
|
|
63
82
|
case result[:status]
|
|
64
83
|
when :submitted
|