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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "uri"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "active_support/core_ext/file/atomic"
|
|
4
6
|
|
|
5
7
|
module Indexmap
|
|
6
8
|
module Storage
|
|
@@ -8,16 +10,21 @@ module Indexmap
|
|
|
8
10
|
DEFAULT_CONTENT_TYPE = "application/xml"
|
|
9
11
|
|
|
10
12
|
def initialize(path:, public_url: nil)
|
|
11
|
-
@path = Pathname(path)
|
|
13
|
+
@path = Pathname(path).expand_path
|
|
14
|
+
@path.mkpath
|
|
15
|
+
@path = @path.realpath
|
|
12
16
|
@public_url_base = public_url
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def write(filename, body, content_type: DEFAULT_CONTENT_TYPE)
|
|
20
|
+
if Publication.immutable?(filename) && exist?(filename)
|
|
21
|
+
raise ConfigurationError, "Immutable sitemap already exists: #{filename}"
|
|
22
|
+
end
|
|
16
23
|
target = path_for(filename)
|
|
17
24
|
target.dirname.mkpath
|
|
18
|
-
target.write(body.to_s)
|
|
25
|
+
::File.atomic_write(target) { |file| file.write(body.to_s) }
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
Artifact.new(
|
|
21
28
|
filename: normalize_filename(filename),
|
|
22
29
|
body: body.to_s,
|
|
23
30
|
content_type: content_type || DEFAULT_CONTENT_TYPE
|
|
@@ -33,13 +40,32 @@ module Indexmap
|
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
def list(prefix: nil, suffix: nil)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
prefix = prefix.to_s
|
|
44
|
+
unless prefix.empty? || normalize_filename(prefix) == prefix.delete_suffix("/")
|
|
45
|
+
raise ArgumentError, "Storage prefix must be a normalized relative path: #{prefix.inspect}"
|
|
46
|
+
end
|
|
47
|
+
directory, _, fragment = prefix.rpartition("/")
|
|
48
|
+
ancestor = path
|
|
49
|
+
directory.split("/").each do |segment|
|
|
50
|
+
ancestor = ancestor.join(segment)
|
|
51
|
+
return [] if segment.start_with?(".") || ancestor.symlink?
|
|
52
|
+
end
|
|
53
|
+
base = directory.empty? ? path : path_for(directory)
|
|
54
|
+
return [] unless base.directory?
|
|
55
|
+
|
|
56
|
+
pending = base.children.select { |entry| entry.basename.to_s.start_with?(fragment) }
|
|
57
|
+
filenames = []
|
|
58
|
+
until pending.empty?
|
|
59
|
+
entry = pending.pop
|
|
60
|
+
next if entry.symlink? || entry.basename.to_s.start_with?(".")
|
|
61
|
+
|
|
62
|
+
if entry.directory?
|
|
63
|
+
pending.concat(entry.children)
|
|
64
|
+
elsif (!suffix || entry.to_s.end_with?(suffix)) && entry.file?
|
|
65
|
+
filenames << entry.relative_path_from(path).to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
filenames.sort
|
|
43
69
|
end
|
|
44
70
|
|
|
45
71
|
def delete(filename)
|
|
@@ -52,6 +78,73 @@ module Indexmap
|
|
|
52
78
|
URI.join("#{public_url_base.to_s.delete_suffix("/")}/", normalize_filename(filename)).to_s
|
|
53
79
|
end
|
|
54
80
|
|
|
81
|
+
def open(filename, &block)
|
|
82
|
+
path_for(filename).open("rb", &block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def write_io(filename, io, content_type: DEFAULT_CONTENT_TYPE)
|
|
86
|
+
if Publication.immutable?(filename) && exist?(filename)
|
|
87
|
+
raise ConfigurationError, "Immutable sitemap already exists: #{filename}"
|
|
88
|
+
end
|
|
89
|
+
target = path_for(filename)
|
|
90
|
+
target.dirname.mkpath
|
|
91
|
+
::File.atomic_write(target) { |file| IO.copy_stream(io, file) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def metadata(filename)
|
|
95
|
+
stat = path_for(filename).stat
|
|
96
|
+
{etag: "#{stat.ino}-#{stat.size}-#{stat.mtime.to_r}", bytes: stat.size, last_modified: stat.mtime.utc}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def revision(root)
|
|
100
|
+
exist?(root) ? metadata(root).fetch(:etag) : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def publish(source, files:, root:, expected:)
|
|
104
|
+
check_revision!(root, expected)
|
|
105
|
+
publication = Publication.new(root: root)
|
|
106
|
+
publication.validate_files!(files)
|
|
107
|
+
with_staging(root, expected: expected) do |staged|
|
|
108
|
+
files.each do |filename|
|
|
109
|
+
source.open(filename) { |io| staged.write_io(filename, io) }
|
|
110
|
+
end
|
|
111
|
+
archive = if expected && (version = publication.current_generation(self))
|
|
112
|
+
publication.archive(version).tap do |filename|
|
|
113
|
+
self.open(root) { |io| staged.write_io(filename, io) }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
with_lock(root) do
|
|
117
|
+
check_revision!(root, expected)
|
|
118
|
+
children = files.reject { |filename| filename == root }
|
|
119
|
+
children.each do |filename|
|
|
120
|
+
raise ConfigurationError, "Immutable sitemap already exists: #{filename}" if exist?(filename)
|
|
121
|
+
end
|
|
122
|
+
moved = []
|
|
123
|
+
root_revision = staged.revision(root)
|
|
124
|
+
begin
|
|
125
|
+
children.each do |filename|
|
|
126
|
+
move_staged(staged, filename)
|
|
127
|
+
moved << filename
|
|
128
|
+
end
|
|
129
|
+
move_staged(staged, archive, retired_at: Time.now.utc) if archive
|
|
130
|
+
move_staged(staged, root)
|
|
131
|
+
ensure
|
|
132
|
+
# A root rename can succeed immediately before an interrupt.
|
|
133
|
+
moved.each { |filename| delete(filename) } unless revision(root) == root_revision
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
rescue Errno::ENOENT
|
|
138
|
+
check_revision!(root, expected)
|
|
139
|
+
raise
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def prune(root:, before:)
|
|
143
|
+
with_lock(root) do
|
|
144
|
+
Publication.new(root: root).prune(self, before: before) + prune_staging(root, before: before)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
55
148
|
def inspect
|
|
56
149
|
"#<#{self.class.name} path=#{path}>"
|
|
57
150
|
end
|
|
@@ -60,8 +153,86 @@ module Indexmap
|
|
|
60
153
|
|
|
61
154
|
attr_reader :path, :public_url_base
|
|
62
155
|
|
|
156
|
+
def check_revision!(root, expected)
|
|
157
|
+
raise SupersededPublication, "Sitemap changed while building: #{root}" unless revision(root) == expected
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def staging_path(root)
|
|
161
|
+
path_for(".indexmap-builds/#{Digest::SHA256.hexdigest(root)}")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def with_staging(root, expected:)
|
|
165
|
+
directory = nil
|
|
166
|
+
lock = nil
|
|
167
|
+
with_lock(root) do
|
|
168
|
+
check_revision!(root, expected)
|
|
169
|
+
base = staging_path(root)
|
|
170
|
+
base.mkpath
|
|
171
|
+
directory = Dir.mktmpdir("build-", base)
|
|
172
|
+
lock = ::File.open(::File.join(directory, ".lock"), "w")
|
|
173
|
+
lock.flock(::File::LOCK_EX)
|
|
174
|
+
end
|
|
175
|
+
yield Filesystem.new(path: directory)
|
|
176
|
+
ensure
|
|
177
|
+
begin
|
|
178
|
+
FileUtils.remove_entry(directory) if directory
|
|
179
|
+
ensure
|
|
180
|
+
lock&.close
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def move_staged(staged, filename, retired_at: nil)
|
|
185
|
+
target = path_for(filename)
|
|
186
|
+
target.dirname.mkpath
|
|
187
|
+
staged.open(filename) do |io|
|
|
188
|
+
::File.utime(retired_at, retired_at, io.path) if retired_at
|
|
189
|
+
::File.atomic_write(target) do |temporary|
|
|
190
|
+
temporary.close
|
|
191
|
+
::File.rename(io.path, temporary.path)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def prune_staging(root, before:)
|
|
197
|
+
base = staging_path(root)
|
|
198
|
+
return [] unless base.directory?
|
|
199
|
+
|
|
200
|
+
base.children.filter_map do |directory|
|
|
201
|
+
next if directory.symlink? || !directory.directory? || directory.mtime >= before
|
|
202
|
+
|
|
203
|
+
begin
|
|
204
|
+
directory.join(".lock").open("a") do |lock|
|
|
205
|
+
next unless lock.flock(::File::LOCK_EX | ::File::LOCK_NB)
|
|
206
|
+
|
|
207
|
+
FileUtils.remove_entry_secure(directory.to_s)
|
|
208
|
+
directory.relative_path_from(path).to_s
|
|
209
|
+
end
|
|
210
|
+
rescue Errno::ENOENT
|
|
211
|
+
# A completed build can remove its staging directory during pruning.
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def with_lock(root)
|
|
218
|
+
lock = path_for(".indexmap-#{Digest::SHA256.hexdigest(root)}.lock")
|
|
219
|
+
lock.open("a") do |file|
|
|
220
|
+
file.flock(::File::LOCK_EX)
|
|
221
|
+
yield
|
|
222
|
+
ensure
|
|
223
|
+
file.flock(::File::LOCK_UN)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
63
227
|
def path_for(filename)
|
|
64
|
-
path.join(normalize_filename(filename))
|
|
228
|
+
target = path.join(normalize_filename(filename))
|
|
229
|
+
ancestor = target
|
|
230
|
+
ancestor = ancestor.parent until ancestor.exist? || ancestor.symlink?
|
|
231
|
+
resolved = ancestor.realpath
|
|
232
|
+
unless resolved == path || resolved.to_s.start_with?("#{path}/")
|
|
233
|
+
raise ArgumentError, "Storage filename escapes its root: #{filename.inspect}"
|
|
234
|
+
end
|
|
235
|
+
target
|
|
65
236
|
end
|
|
66
237
|
|
|
67
238
|
def normalize_filename(filename)
|
|
@@ -17,7 +17,7 @@ module Indexmap
|
|
|
17
17
|
|
|
18
18
|
def write(filename, body, content_type: DEFAULT_CONTENT_TYPE)
|
|
19
19
|
normalized = normalize_filename(filename)
|
|
20
|
-
files[normalized] =
|
|
20
|
+
files[normalized] = Artifact.new(
|
|
21
21
|
filename: normalized,
|
|
22
22
|
body: body.to_s,
|
|
23
23
|
content_type: content_type || DEFAULT_CONTENT_TYPE
|
data/lib/indexmap/task_runner.rb
CHANGED
|
@@ -4,24 +4,29 @@ require "nokogiri"
|
|
|
4
4
|
|
|
5
5
|
module Indexmap
|
|
6
6
|
class TaskRunner
|
|
7
|
-
def initialize(configuration: Indexmap.configuration)
|
|
7
|
+
def initialize(configuration: Indexmap.configuration, storage: nil)
|
|
8
8
|
@configuration = configuration
|
|
9
|
+
@storage = storage
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def create
|
|
12
|
-
|
|
13
|
+
creator = Creator.new(output: default_output, storage: storage)
|
|
14
|
+
written_files = creator.create
|
|
13
15
|
index_now_key_filename = write_index_now_key if configuration.index_now.write_key_file?
|
|
14
16
|
configuration.run_after_create_callbacks
|
|
15
17
|
|
|
16
18
|
{
|
|
17
19
|
files: written_files.map(&:to_s),
|
|
18
20
|
written_files: written_files,
|
|
19
|
-
sitemaps:
|
|
21
|
+
sitemaps: creator.sitemaps,
|
|
20
22
|
index_now_key_filename: index_now_key_filename
|
|
21
23
|
}
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def format
|
|
27
|
+
if configuration.output_names.any? { |name| configuration.output_for(name).atomic }
|
|
28
|
+
raise ConfigurationError, "Format immutable sitemaps by regenerating them"
|
|
29
|
+
end
|
|
25
30
|
sitemap_files.each do |filename|
|
|
26
31
|
content = storage.read(filename)
|
|
27
32
|
document = Nokogiri::XML(
|
|
@@ -39,19 +44,29 @@ module Indexmap
|
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def validate
|
|
42
|
-
Validator.new(configuration: configuration).validate!
|
|
47
|
+
Validator.new(configuration: configuration, storage: storage).validate!(collect: false)
|
|
43
48
|
sitemap_files
|
|
44
49
|
end
|
|
45
50
|
|
|
51
|
+
def prune(retention: 48 * 3600)
|
|
52
|
+
raise ConfigurationError, "Retention must be at least one hour" unless retention.is_a?(Numeric) && retention >= 3600
|
|
53
|
+
raise ConfigurationError, "Storage does not support pruning" unless storage.respond_to?(:prune)
|
|
54
|
+
|
|
55
|
+
configuration.output_names.flat_map do |name|
|
|
56
|
+
output = configuration.output_for(name)
|
|
57
|
+
output.atomic ? storage.prune(root: output.index_filename, before: Time.now.utc - retention) : []
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
46
61
|
def write_index_now_key(generate_if_missing: false)
|
|
47
|
-
pinger = Pinger::IndexNow.new(configuration: configuration)
|
|
62
|
+
pinger = Pinger::IndexNow.new(configuration: configuration, storage: storage)
|
|
48
63
|
return pinger.ensure_key_file if generate_if_missing
|
|
49
64
|
|
|
50
65
|
pinger.write_key_file
|
|
51
66
|
end
|
|
52
67
|
|
|
53
68
|
def storage
|
|
54
|
-
configuration.storage
|
|
69
|
+
@storage ||= configuration.storage
|
|
55
70
|
end
|
|
56
71
|
|
|
57
72
|
private
|
|
@@ -65,27 +80,5 @@ module Indexmap
|
|
|
65
80
|
def sitemap_files
|
|
66
81
|
storage.list(prefix: "sitemap", suffix: ".xml")
|
|
67
82
|
end
|
|
68
|
-
|
|
69
|
-
def sitemap_details(files)
|
|
70
|
-
files.map do |filename|
|
|
71
|
-
{
|
|
72
|
-
filename: filename.to_s,
|
|
73
|
-
location: sitemap_location(filename),
|
|
74
|
-
link_count: sitemap_link_count(filename)
|
|
75
|
-
}
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def sitemap_location(filename)
|
|
80
|
-
return storage.public_url(filename) if storage.respond_to?(:public_url)
|
|
81
|
-
|
|
82
|
-
filename.to_s
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def sitemap_link_count(filename)
|
|
86
|
-
document = Nokogiri::XML(storage.read(filename.to_s))
|
|
87
|
-
document.remove_namespaces!
|
|
88
|
-
document.xpath("//loc").count
|
|
89
|
-
end
|
|
90
83
|
end
|
|
91
84
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Indexmap
|
|
4
|
+
class Validator
|
|
5
|
+
class Duplicates
|
|
6
|
+
def initialize(directory:)
|
|
7
|
+
@directory = directory
|
|
8
|
+
@buffer = []
|
|
9
|
+
@runs = []
|
|
10
|
+
@sequence = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add(location)
|
|
14
|
+
@buffer << location
|
|
15
|
+
flush if @buffer.size == 10_000
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate!
|
|
19
|
+
previous = nil
|
|
20
|
+
each_sorted_location do |line|
|
|
21
|
+
raise ValidationError, "Duplicate sitemap URLs detected: #{line.chomp}" if line == previous
|
|
22
|
+
|
|
23
|
+
previous = line
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def unique_count
|
|
28
|
+
count = 0
|
|
29
|
+
previous = nil
|
|
30
|
+
each_sorted_location do |line|
|
|
31
|
+
count += 1 unless line == previous
|
|
32
|
+
previous = line
|
|
33
|
+
end
|
|
34
|
+
count
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def each_sorted_location
|
|
40
|
+
flush unless @buffer.empty?
|
|
41
|
+
while @runs.size > 1
|
|
42
|
+
@runs = @runs.each_slice(2).map do |left, right|
|
|
43
|
+
next left unless right
|
|
44
|
+
|
|
45
|
+
target = next_path
|
|
46
|
+
File.open(left) do |a|
|
|
47
|
+
File.open(right) do |b|
|
|
48
|
+
File.open(target, "wb") do |output|
|
|
49
|
+
x = a.gets
|
|
50
|
+
y = b.gets
|
|
51
|
+
while x || y
|
|
52
|
+
if y.nil? || (x && x <= y)
|
|
53
|
+
output.write(x)
|
|
54
|
+
x = a.gets
|
|
55
|
+
else
|
|
56
|
+
output.write(y)
|
|
57
|
+
y = b.gets
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
File.unlink(left, right)
|
|
64
|
+
target
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
File.foreach(@runs.first) { |line| yield line } if @runs.first
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def flush
|
|
71
|
+
path = next_path
|
|
72
|
+
File.open(path, "wb") { |file| @buffer.sort.each { |location| file.puts(location) } }
|
|
73
|
+
@runs << path
|
|
74
|
+
@buffer.clear
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def next_path
|
|
78
|
+
@sequence += 1
|
|
79
|
+
File.join(@directory, "urls-#{@sequence}")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|