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/docs/storage.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Storage contract
|
|
2
|
+
|
|
3
|
+
> Atomic publication and streamed delivery describe unreleased main.
|
|
4
|
+
> See [release status](../README.md#release-status).
|
|
5
|
+
|
|
6
|
+
All outputs share `config.storage`. Operation instances resolve storage lazily
|
|
7
|
+
once; construct a new runner, validator, creator, or pinger for a new storage
|
|
8
|
+
context. Collaborators receive the same adapter through `storage:`. Configure
|
|
9
|
+
public URLs where crawlers can fetch artifacts, even when the backing objects are private. Built-in persistent
|
|
10
|
+
adapters are `Indexmap::Storage::Filesystem` and
|
|
11
|
+
`Indexmap::Storage::ActiveStorage`; the latter uses a host-owned model and attachment.
|
|
12
|
+
`Storage::Memory` is useful in tests and retains entire bodies in memory.
|
|
13
|
+
|
|
14
|
+
## Legacy adapters
|
|
15
|
+
|
|
16
|
+
The six-method contract remains supported without atomic publication:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
storage.write(filename, body, content_type: "application/xml")
|
|
20
|
+
storage.read(filename) # XML string; missing keys should raise
|
|
21
|
+
storage.exist?(filename) # boolean
|
|
22
|
+
storage.list(prefix: nil, suffix: nil) # sorted relative keys matching both filters
|
|
23
|
+
storage.delete(filename)
|
|
24
|
+
storage.public_url(filename) # public URL for this key
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`prefix` and `suffix` match the whole relative key, not just its basename.
|
|
28
|
+
Write/delete return values are not used by the generation pipeline. Custom
|
|
29
|
+
adapters must reject traversal outside their storage namespace and accept nested
|
|
30
|
+
relative keys. Restrict the configured filesystem directory to sitemap artifacts:
|
|
31
|
+
its `list` method restricts traversal to the prefix's literal directory and
|
|
32
|
+
leading filename fragment. Calls without a prefix scan the whole visible tree.
|
|
33
|
+
Hidden paths and symlinks are excluded; prefixes must be normalized relative
|
|
34
|
+
paths without traversal, absolute paths, or redundant separators.
|
|
35
|
+
|
|
36
|
+
## Streaming and metadata
|
|
37
|
+
|
|
38
|
+
These optional methods avoid whole-file read/write fallbacks during generation
|
|
39
|
+
and validation. Both are mandatory for the sitemap HTTP controller's reads:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
storage.open(filename) { |io| ... } # readable binary IO, closed after the block
|
|
43
|
+
storage.metadata(filename) # {etag:, bytes:, last_modified:}
|
|
44
|
+
storage.write_io(filename, io, content_type: "application/xml")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
An ETag must change whenever content changes; `bytes` is uncompressed byte length
|
|
48
|
+
and `last_modified` is a time. Active Storage also provides `generation` metadata
|
|
49
|
+
for active roots. `write_io` reads from the current IO position. Validation and
|
|
50
|
+
parsing use `read` when `open` plus `metadata` are unavailable. Generation uses
|
|
51
|
+
`write` when `write_io` is unavailable. These fallbacks retain a whole bounded
|
|
52
|
+
file in memory.
|
|
53
|
+
|
|
54
|
+
## Atomic publication and pruning
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
storage.revision(root) # opaque revision token; nil when the root does not exist
|
|
58
|
+
storage.publish(staged_storage, files: filenames, root: root, expected: revision)
|
|
59
|
+
storage.prune(root: root, before: cutoff_time) # deleted relative paths
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`files` lists staged children and the root. `staged_storage.open(key)` supplies
|
|
63
|
+
validated XML. An atomic adapter must preserve immutable child keys, coordinate
|
|
64
|
+
activation against `expected`, and raise `Indexmap::SupersededPublication` on a
|
|
65
|
+
revision mismatch. Root replacement must expose a complete publication; preserve
|
|
66
|
+
previously published children for cached roots. A failed publish must not activate
|
|
67
|
+
a partial root. Built-in adapters clean up ordinary failed builds immediately;
|
|
68
|
+
crashes/outages can still leave orphans. `publish` has no required return value.
|
|
69
|
+
|
|
70
|
+
Atomic mode requires `revision` and `publish`; pruning additionally requires
|
|
71
|
+
`prune`. The shared HTTP controller uses the built-in `Publication` ownership and
|
|
72
|
+
archive naming contract. Custom atomic storage intended for that controller must
|
|
73
|
+
also preserve those archives and expose `open`/metadata. Do not claim controller
|
|
74
|
+
compatibility from `publish` alone.
|
|
75
|
+
|
|
76
|
+
Pruning must coordinate with activation, preserve the current generation and
|
|
77
|
+
other outputs, and remove only expired artifacts owned by the selected root.
|
|
78
|
+
Archive retirement time controls the retained generation's grace period.
|
|
79
|
+
|
|
80
|
+
Active Storage rejects stale builds before uploading, checks again before each
|
|
81
|
+
upload, and destroys/purges its failed build's new records and blobs outside the
|
|
82
|
+
root lock. Activation verifies child existence under that lock. Work already in
|
|
83
|
+
flight can be lost to a race; crashes still need retention/unattached cleanup.
|
|
84
|
+
`PublicationCleanupError#failures` identifies cleanup artifacts and errors; the
|
|
85
|
+
original publication error remains its cause. Preserve object-key diagnostics if
|
|
86
|
+
service deletion fails after a blob row has been destroyed.
|
|
87
|
+
|
|
88
|
+
Filesystem copies and root-archive preparation occur in private destination
|
|
89
|
+
staging outside the root lock. Activation uses renames under the lock. Each build
|
|
90
|
+
holds its own staging lock; prune skips live builds and reclaims abandoned
|
|
91
|
+
staging after the grace period, returning private directory paths too. See
|
|
92
|
+
[publication and retention](dynamic-sitemaps.md#atomic-publication-and-storage)
|
|
93
|
+
for filesystem semantics, the required unique Active Storage filename index,
|
|
94
|
+
attachment cleanup, and retention operations.
|
|
@@ -28,7 +28,7 @@ module Indexmap
|
|
|
28
28
|
indexmap_tasks_requested = Rake.application.options.show_tasks ||
|
|
29
29
|
ARGV.any? { |argument| ["-T", "--tasks"].include?(argument) }
|
|
30
30
|
require "indexmap/tasks" if indexmap_tasks_requested ||
|
|
31
|
-
ARGV.any? { |argument| argument.start_with?("indexmap:") }
|
|
31
|
+
(Rake.application.top_level_tasks + ARGV).any? { |argument| argument.start_with?("indexmap:") }
|
|
32
32
|
|
|
33
33
|
RUBY
|
|
34
34
|
end
|
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
4
|
-
|
|
3
|
+
module Indexmap
|
|
4
|
+
module HostConfiguration
|
|
5
|
+
MUTEX = Mutex.new
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
return if @applied
|
|
8
|
-
|
|
9
|
-
MUTEX.synchronize do
|
|
7
|
+
def self.apply
|
|
10
8
|
return if @applied
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
MUTEX.synchronize do
|
|
11
|
+
return if @applied
|
|
12
|
+
|
|
13
|
+
require "indexmap"
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Indexmap::Storage::Filesystem.new(
|
|
15
|
+
Indexmap.configure do |config|
|
|
16
|
+
config.base_url = -> { ENV.fetch("INDEXMAP_BASE_URL", "http://localhost:3000") }
|
|
17
|
+
config.storage = Indexmap::Storage::Filesystem.new(
|
|
18
18
|
path: Rails.public_path,
|
|
19
19
|
public_url: config.base_url
|
|
20
20
|
)
|
|
21
|
-
}
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
# Search-engine pings start from config.index_filename (default: "sitemap.xml").
|
|
23
|
+
# Configure the sitemap data owned by your application:
|
|
24
|
+
# config.sections = -> { Sitemap.sections }
|
|
25
|
+
end
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
@applied = true
|
|
28
|
+
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Indexmap::HostConfiguration.apply if defined?(Indexmap::Configuration)
|
|
@@ -4,6 +4,8 @@ module Indexmap
|
|
|
4
4
|
class Configuration
|
|
5
5
|
VALID_FORMATS = %i[index single_file].freeze
|
|
6
6
|
|
|
7
|
+
attr_accessor :atomic, :max_urls, :max_bytes
|
|
8
|
+
|
|
7
9
|
attr_writer :base_url, :entries, :format, :index_filename, :sections, :storage
|
|
8
10
|
|
|
9
11
|
def initialize
|
|
@@ -18,7 +20,8 @@ module Indexmap
|
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def entries
|
|
21
|
-
|
|
23
|
+
value = resolve(@entries)
|
|
24
|
+
value.is_a?(Enumerable) ? value : Array(value)
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def format
|
|
@@ -38,6 +41,10 @@ module Indexmap
|
|
|
38
41
|
@index_now ||= IndexNowConfiguration.new
|
|
39
42
|
end
|
|
40
43
|
|
|
44
|
+
def robots
|
|
45
|
+
@robots ||= Robots.new(configuration: self)
|
|
46
|
+
end
|
|
47
|
+
|
|
41
48
|
def storage
|
|
42
49
|
resolve(@storage) || Storage::Filesystem.new(path: "public", public_url: base_url)
|
|
43
50
|
end
|
|
@@ -46,6 +53,10 @@ module Indexmap
|
|
|
46
53
|
Array(resolve(@sections))
|
|
47
54
|
end
|
|
48
55
|
|
|
56
|
+
def output_names
|
|
57
|
+
[:default, *@outputs.keys].uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
49
60
|
def output(name)
|
|
50
61
|
output = output_for(name)
|
|
51
62
|
yield(output) if block_given?
|
data/lib/indexmap/creator.rb
CHANGED
|
@@ -1,62 +1,65 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module Indexmap
|
|
6
7
|
class Creator
|
|
7
8
|
ValidationConfiguration = Struct.new(:base_url, :index_filename, :storage)
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
attr_reader :sitemaps
|
|
11
|
+
|
|
12
|
+
def initialize(output:, storage: nil)
|
|
10
13
|
@output = output
|
|
14
|
+
@storage = storage
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def create
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
atomic = output.atomic
|
|
19
|
+
root = output.index_filename
|
|
20
|
+
raise ConfigurationError, "Root filename uses the reserved immutable namespace" if Publication.immutable?(root)
|
|
21
|
+
if atomic && !(storage.respond_to?(:revision) && storage.respond_to?(:publish))
|
|
22
|
+
raise ConfigurationError, "Atomic publication requires storage revision and publish capabilities"
|
|
23
|
+
end
|
|
24
|
+
expected = storage.revision(root) if atomic
|
|
25
|
+
writer = output.writer(generation: atomic ? SecureRandom.hex(16) : nil)
|
|
26
|
+
files = []
|
|
27
|
+
Dir.mktmpdir("indexmap-build") do |directory|
|
|
28
|
+
staged = Storage::Filesystem.new(path: directory)
|
|
29
|
+
writer.each_file do |file|
|
|
30
|
+
raise ConfigurationError, "Duplicate sitemap filename: #{file.filename}" if staged.exist?(file.filename)
|
|
31
|
+
|
|
32
|
+
staged.write(file.filename, file.body, content_type: file.content_type)
|
|
33
|
+
files << file.filename
|
|
34
|
+
end
|
|
35
|
+
Validator.new(configuration: ValidationConfiguration.new(
|
|
36
|
+
base_url: output.base_url, index_filename: root, storage: staged
|
|
37
|
+
)).validate!(collect: false)
|
|
38
|
+
if atomic
|
|
39
|
+
storage.publish(staged, files: files, root: root, expected: expected)
|
|
40
|
+
else
|
|
41
|
+
files.each do |filename|
|
|
42
|
+
if storage.respond_to?(:write_io)
|
|
43
|
+
staged.open(filename) { |io| storage.write_io(filename, io, content_type: "application/xml") }
|
|
44
|
+
else
|
|
45
|
+
storage.write(filename, staged.read(filename), content_type: "application/xml")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
@sitemaps = files.map do |filename|
|
|
51
|
+
location = storage.respond_to?(:public_url) ? storage.public_url(filename) : filename
|
|
52
|
+
{filename: filename, location: location, link_count: writer.counts.fetch(filename)}
|
|
53
|
+
end
|
|
54
|
+
files
|
|
18
55
|
end
|
|
19
56
|
|
|
20
57
|
private
|
|
21
58
|
|
|
22
59
|
attr_reader :output
|
|
23
60
|
|
|
24
|
-
def
|
|
25
|
-
output.
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def format(files)
|
|
29
|
-
files.map do |file|
|
|
30
|
-
document = Nokogiri::XML(
|
|
31
|
-
file.body,
|
|
32
|
-
nil,
|
|
33
|
-
nil,
|
|
34
|
-
Nokogiri::XML::ParseOptions::DEFAULT_XML | Nokogiri::XML::ParseOptions::NOBLANKS
|
|
35
|
-
)
|
|
36
|
-
save_options = Nokogiri::XML::Node::SaveOptions::FORMAT | Nokogiri::XML::Node::SaveOptions::AS_XML
|
|
37
|
-
|
|
38
|
-
Storage::File.new(
|
|
39
|
-
filename: file.filename,
|
|
40
|
-
body: document.to_xml(indent: 2, save_with: save_options),
|
|
41
|
-
content_type: file.content_type
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def validate(files)
|
|
47
|
-
Validator.new(
|
|
48
|
-
configuration: ValidationConfiguration.new(
|
|
49
|
-
base_url: output.base_url,
|
|
50
|
-
index_filename: output.index_filename,
|
|
51
|
-
storage: Storage::Memory.new(files)
|
|
52
|
-
)
|
|
53
|
-
).validate!
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def publish(files)
|
|
57
|
-
files.each do |file|
|
|
58
|
-
output.storage.write(file.filename, file.body, content_type: file.content_type)
|
|
59
|
-
end
|
|
61
|
+
def storage
|
|
62
|
+
@storage ||= output.storage
|
|
60
63
|
end
|
|
61
64
|
end
|
|
62
65
|
end
|
|
@@ -5,6 +5,8 @@ module Indexmap
|
|
|
5
5
|
DEFAULT_ENDPOINT = "https://api.indexnow.org"
|
|
6
6
|
DEFAULT_MAX_URLS_PER_REQUEST = 500
|
|
7
7
|
|
|
8
|
+
attr_accessor :max_attempts, :max_wait
|
|
9
|
+
|
|
8
10
|
attr_writer :dry_run, :endpoint, :key, :key_filename, :max_urls_per_request, :write_key_file
|
|
9
11
|
|
|
10
12
|
def dry_run?
|
|
@@ -40,7 +42,7 @@ module Indexmap
|
|
|
40
42
|
value = resolve(@max_urls_per_request)
|
|
41
43
|
return DEFAULT_MAX_URLS_PER_REQUEST if value.nil?
|
|
42
44
|
|
|
43
|
-
value.
|
|
45
|
+
Integer(value.to_s, 10)
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
private
|
data/lib/indexmap/output.rb
CHANGED
|
@@ -4,6 +4,8 @@ module Indexmap
|
|
|
4
4
|
class Output
|
|
5
5
|
VALID_FORMATS = %i[index single_file].freeze
|
|
6
6
|
|
|
7
|
+
attr_writer :atomic, :max_urls, :max_bytes
|
|
8
|
+
|
|
7
9
|
attr_writer :base_url, :entries, :format, :index_filename, :sections
|
|
8
10
|
|
|
9
11
|
def initialize(configuration:)
|
|
@@ -17,7 +19,8 @@ module Indexmap
|
|
|
17
19
|
def entries
|
|
18
20
|
resolved_entries = resolve(@entries)
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
value = resolved_entries.nil? ? configuration.entries : resolved_entries
|
|
23
|
+
value.is_a?(Enumerable) ? value : Array(value)
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def format
|
|
@@ -39,7 +42,13 @@ module Indexmap
|
|
|
39
42
|
Array(resolved_sections.nil? ? configuration.sections : resolved_sections)
|
|
40
43
|
end
|
|
41
44
|
|
|
42
|
-
def
|
|
45
|
+
def atomic
|
|
46
|
+
@atomic.nil? ? configuration.atomic == true : @atomic == true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def writer(generation: nil)
|
|
50
|
+
base_url = self.base_url
|
|
51
|
+
format = self.format
|
|
43
52
|
raise ConfigurationError, "Indexmap base_url is not configured" if base_url.to_s.strip.empty?
|
|
44
53
|
|
|
45
54
|
unless VALID_FORMATS.include?(format)
|
|
@@ -47,8 +56,10 @@ module Indexmap
|
|
|
47
56
|
end
|
|
48
57
|
|
|
49
58
|
if format == :single_file
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
entries = self.entries
|
|
60
|
+
raise ConfigurationError, "Indexmap entries are not configured" if entries.is_a?(Array) && entries.empty?
|
|
61
|
+
else
|
|
62
|
+
sections = self.sections
|
|
52
63
|
raise ConfigurationError, "Indexmap sections are not configured" if sections.empty?
|
|
53
64
|
end
|
|
54
65
|
|
|
@@ -57,7 +68,10 @@ module Indexmap
|
|
|
57
68
|
format: format,
|
|
58
69
|
sections: sections,
|
|
59
70
|
base_url: base_url,
|
|
60
|
-
index_filename: index_filename
|
|
71
|
+
index_filename: index_filename,
|
|
72
|
+
max_urls: @max_urls || configuration.max_urls || Validator::MAX_ENTRIES,
|
|
73
|
+
max_bytes: @max_bytes || configuration.max_bytes || Validator::MAX_BYTES,
|
|
74
|
+
generation: generation
|
|
61
75
|
)
|
|
62
76
|
end
|
|
63
77
|
|
data/lib/indexmap/parser.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "nokogiri"
|
|
5
5
|
require "uri"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "stringio"
|
|
6
8
|
|
|
7
9
|
module Indexmap
|
|
8
10
|
class Parser
|
|
@@ -19,8 +21,49 @@ module Indexmap
|
|
|
19
21
|
return reset! && entries if reset
|
|
20
22
|
return @entries if defined?(@entries)
|
|
21
23
|
|
|
24
|
+
@entries = each_entry.to_a
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Unlike entries, this does not cache or retain the URL inventory.
|
|
28
|
+
def each_entry
|
|
29
|
+
return enum_for(:each_entry) unless block_given?
|
|
30
|
+
|
|
22
31
|
visited = Set.new
|
|
23
|
-
|
|
32
|
+
pending = [@source]
|
|
33
|
+
until pending.empty?
|
|
34
|
+
source = normalize_source(pending.pop)
|
|
35
|
+
next if source.nil? || visited.include?(source)
|
|
36
|
+
|
|
37
|
+
visited.add(source)
|
|
38
|
+
children = []
|
|
39
|
+
open_source(source) do |io|
|
|
40
|
+
reader = Nokogiri::XML::Reader.from_io(io, nil, nil, Nokogiri::XML::ParseOptions::NONET)
|
|
41
|
+
root = nil
|
|
42
|
+
reader.each do |node|
|
|
43
|
+
if node.node_type == Nokogiri::XML::Reader::TYPE_DOCUMENT_TYPE
|
|
44
|
+
raise ValidationError, "Sitemap DTDs are not allowed: #{source}"
|
|
45
|
+
end
|
|
46
|
+
next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
47
|
+
|
|
48
|
+
root = node.local_name if node.depth == 0
|
|
49
|
+
next unless node.depth == 1
|
|
50
|
+
|
|
51
|
+
if root == "sitemapindex" && node.local_name == "sitemap"
|
|
52
|
+
fields = read_fields(reader)
|
|
53
|
+
child = resolve_child_sitemap(source, fields.fetch("loc").to_s.strip)
|
|
54
|
+
children << child if child
|
|
55
|
+
elsif root == "urlset" && node.local_name == "url"
|
|
56
|
+
fields = read_fields(reader)
|
|
57
|
+
loc = fields.fetch("loc").to_s.strip
|
|
58
|
+
next if loc.empty?
|
|
59
|
+
|
|
60
|
+
lastmod = fields.fetch("lastmod").to_s.strip
|
|
61
|
+
yield Entry.new(loc: loc, lastmod: lastmod.empty? ? nil : lastmod, source_sitemap: source)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
pending.concat(children.reverse)
|
|
66
|
+
end
|
|
24
67
|
end
|
|
25
68
|
|
|
26
69
|
def paths(reset: false)
|
|
@@ -60,33 +103,26 @@ module Indexmap
|
|
|
60
103
|
|
|
61
104
|
attr_reader :index_filename, :storage
|
|
62
105
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
106
|
+
def read_fields(reader)
|
|
107
|
+
fields = {"loc" => nil, "lastmod" => nil}
|
|
108
|
+
return fields if reader.self_closing?
|
|
109
|
+
|
|
110
|
+
text = nil
|
|
111
|
+
while reader.read
|
|
112
|
+
type = reader.node_type
|
|
113
|
+
if type == Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
114
|
+
break if reader.depth == 1
|
|
115
|
+
|
|
116
|
+
text = nil if reader.depth == 2
|
|
117
|
+
elsif type == Nokogiri::XML::Reader::TYPE_ELEMENT && reader.depth == 2
|
|
118
|
+
name = reader.local_name
|
|
119
|
+
text = fields.key?(name) ? (fields[name] = +"") : nil
|
|
120
|
+
elsif text && [Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_CDATA,
|
|
121
|
+
Nokogiri::XML::Reader::TYPE_WHITESPACE, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE].include?(type)
|
|
122
|
+
text << reader.value
|
|
80
123
|
end
|
|
81
|
-
else
|
|
82
|
-
document.xpath("//url").map do |url_node|
|
|
83
|
-
loc = url_node.at_xpath("loc")&.text.to_s.strip
|
|
84
|
-
next if loc.empty?
|
|
85
|
-
|
|
86
|
-
lastmod = url_node.at_xpath("lastmod")&.text.to_s.strip
|
|
87
|
-
Entry.new(loc: loc, lastmod: lastmod.empty? ? nil : lastmod, source_sitemap: normalized_source)
|
|
88
|
-
end.compact
|
|
89
124
|
end
|
|
125
|
+
fields
|
|
90
126
|
end
|
|
91
127
|
|
|
92
128
|
def resolve_child_sitemap(parent_source, loc)
|
|
@@ -97,7 +133,7 @@ module Indexmap
|
|
|
97
133
|
if remote_source?(loc)
|
|
98
134
|
remote_child_source(parent_uri, loc)
|
|
99
135
|
else
|
|
100
|
-
URI.join(parent_uri.to_s, loc).to_s
|
|
136
|
+
remote_child_source(parent_uri, URI.join(parent_uri.to_s, loc).to_s)
|
|
101
137
|
end
|
|
102
138
|
elsif remote_source?(loc)
|
|
103
139
|
uri = URI.parse(loc)
|
|
@@ -120,12 +156,12 @@ module Indexmap
|
|
|
120
156
|
|
|
121
157
|
def remote_child_source(parent_uri, loc)
|
|
122
158
|
child_uri = URI.parse(loc)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
child_uri
|
|
159
|
+
if @rebase_remote_children
|
|
160
|
+
child_uri.scheme = parent_uri.scheme
|
|
161
|
+
child_uri.host = parent_uri.host
|
|
162
|
+
child_uri.port = parent_uri.port
|
|
163
|
+
end
|
|
164
|
+
check_remote_origin!(child_uri)
|
|
129
165
|
child_uri.to_s
|
|
130
166
|
end
|
|
131
167
|
|
|
@@ -155,35 +191,59 @@ module Indexmap
|
|
|
155
191
|
false
|
|
156
192
|
end
|
|
157
193
|
|
|
158
|
-
def
|
|
194
|
+
def open_source(source)
|
|
159
195
|
if remote_source?(source)
|
|
160
|
-
fetch_remote_source(source)
|
|
196
|
+
body = fetch_remote_source(source)
|
|
197
|
+
yield StringIO.new(body) if body && !body.strip.empty?
|
|
161
198
|
elsif storage.exist?(source)
|
|
162
|
-
storage.
|
|
199
|
+
if storage.respond_to?(:open) && storage.respond_to?(:metadata)
|
|
200
|
+
check_size!(storage.metadata(source).fetch(:bytes))
|
|
201
|
+
storage.open(source) { |io| yield io }
|
|
202
|
+
else
|
|
203
|
+
body = storage.read(source)
|
|
204
|
+
check_size!(body.bytesize)
|
|
205
|
+
yield StringIO.new(body) unless body.strip.empty?
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def check_size!(bytes)
|
|
211
|
+
raise ValidationError, "Sitemap exceeds #{Validator::MAX_BYTES} bytes" if bytes > Validator::MAX_BYTES
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def check_remote_origin!(uri)
|
|
215
|
+
origin = URI.parse(@source)
|
|
216
|
+
unless uri.is_a?(URI::HTTP) && !uri.userinfo &&
|
|
217
|
+
[uri.scheme, uri.host, uri.port] == [origin.scheme, origin.host, origin.port]
|
|
218
|
+
raise ValidationError, "Remote sitemap must use the source origin: #{uri}"
|
|
163
219
|
end
|
|
164
220
|
end
|
|
165
221
|
|
|
166
222
|
def fetch_remote_source(source, redirects_remaining: 3)
|
|
167
223
|
uri = URI.parse(source)
|
|
224
|
+
check_remote_origin!(uri)
|
|
168
225
|
request = Net::HTTP::Get.new(uri)
|
|
169
226
|
request["User-Agent"] = "Indexmap::Parser/1.0"
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
227
|
+
redirected = nil
|
|
228
|
+
body = nil
|
|
229
|
+
|
|
230
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 20) do |http|
|
|
231
|
+
http.request(request) do |response|
|
|
232
|
+
check_size!(response.content_length) if response.content_length
|
|
233
|
+
body = +"" if response.is_a?(Net::HTTPSuccess)
|
|
234
|
+
if response.is_a?(Net::HTTPRedirection)
|
|
235
|
+
location = response["location"].to_s
|
|
236
|
+
redirected = URI.join(source, location).to_s unless location.empty? || redirects_remaining <= 0
|
|
237
|
+
end
|
|
238
|
+
bytes = 0
|
|
239
|
+
response.read_body do |chunk|
|
|
240
|
+
bytes += chunk.bytesize
|
|
241
|
+
check_size!(bytes)
|
|
242
|
+
body << chunk if body
|
|
243
|
+
end
|
|
244
|
+
end
|
|
186
245
|
end
|
|
246
|
+
redirected ? fetch_remote_source(redirected, redirects_remaining: redirects_remaining - 1) : body
|
|
187
247
|
rescue URI::InvalidURIError
|
|
188
248
|
nil
|
|
189
249
|
end
|
data/lib/indexmap/pinger/base.rb
CHANGED
|
@@ -10,8 +10,9 @@ module Indexmap
|
|
|
10
10
|
new(...).ping
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def initialize(configuration: Indexmap.configuration)
|
|
13
|
+
def initialize(configuration: Indexmap.configuration, storage: nil)
|
|
14
14
|
@configuration = configuration
|
|
15
|
+
@storage = storage
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def ping
|
|
@@ -34,6 +35,10 @@ module Indexmap
|
|
|
34
35
|
|
|
35
36
|
attr_reader :configuration
|
|
36
37
|
|
|
38
|
+
def storage
|
|
39
|
+
@storage ||= configuration.storage
|
|
40
|
+
end
|
|
41
|
+
|
|
37
42
|
def host
|
|
38
43
|
configuration.base_url
|
|
39
44
|
end
|
|
@@ -46,10 +51,6 @@ module Indexmap
|
|
|
46
51
|
hostname.sub(/\Awww\./, "")
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
def storage
|
|
50
|
-
configuration.storage
|
|
51
|
-
end
|
|
52
|
-
|
|
53
54
|
def sitemap_files
|
|
54
55
|
index_filename = configuration.index_filename.to_s
|
|
55
56
|
return [] if index_filename.empty? || !storage.exist?(index_filename)
|