websitescreenshotworld 0.1.3

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.
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebsiteScreenshotWorld
4
+ # The file a +by_format+ capture answers with, and its metadata. The body is read once: call {#save},
5
+ # {#read} or {#each_chunk}.
6
+ #
7
+ # Inside the block given to {InstantSnap#capture_file} the body streams from the connection. Without a
8
+ # block it is first buffered to a +Tempfile+, which {#close} deletes.
9
+ class CaptureFile
10
+ CHUNK_SIZE = 64 * 1024
11
+ private_constant :CHUNK_SIZE
12
+
13
+ # @return [String, nil] the capture id
14
+ attr_reader :id
15
+
16
+ # @return [String, nil] +delivered+
17
+ attr_reader :status
18
+
19
+ # @return [String, nil] the file's media type, such as +image/png+
20
+ attr_reader :content_type
21
+
22
+ # @return [String, nil] the file name from +Content-Disposition+
23
+ attr_reader :filename
24
+
25
+ # @return [Integer, nil]
26
+ attr_reader :width
27
+
28
+ # @return [Integer, nil]
29
+ attr_reader :height
30
+
31
+ # @return [Integer, nil]
32
+ attr_reader :size_bytes
33
+
34
+ # @return [Integer, nil] how long the capture took
35
+ attr_reader :duration_ms
36
+
37
+ # @return [Integer, nil] the status the website answered with
38
+ attr_reader :page_http_status
39
+
40
+ # @return [String, nil] the default set the capture was taken under
41
+ attr_reader :spec_version
42
+
43
+ # @return [Time, nil]
44
+ attr_reader :captured_at
45
+
46
+ # @return [String, nil] where the browser ended up; absent when that URL is not plain ASCII
47
+ attr_reader :final_url
48
+
49
+ # @return [String, nil] lowercase hex digest of the file
50
+ attr_reader :sha256
51
+
52
+ # @return [Integer, nil] the number of parts, only when the answer has more than one
53
+ attr_reader :part_count
54
+
55
+ # @api private
56
+ def self.buffered(response)
57
+ tempfile = Tempfile.new("websitescreenshotworld", binmode: true)
58
+ Transport.each_chunk(response) { |chunk| tempfile.write(chunk) }
59
+ tempfile.rewind
60
+ new(response, tempfile: tempfile)
61
+ rescue StandardError
62
+ tempfile&.close!
63
+ raise
64
+ end
65
+
66
+ # @api private
67
+ def initialize(response, tempfile: nil)
68
+ @response = response
69
+ @tempfile = tempfile
70
+ @consumed = false
71
+ read_metadata(response)
72
+ end
73
+
74
+ # Yields the file in chunks as they arrive.
75
+ #
76
+ # @yieldparam chunk [String] binary bytes
77
+ # @return [Enumerator, nil] an enumerator when no block is given
78
+ # @raise [IOError] when the body has already been read
79
+ # @raise [ConnectionError] when the connection breaks mid-file
80
+ def each_chunk(&block)
81
+ return enum_for(:each_chunk) unless block
82
+ raise IOError, "the capture file has already been read" if @consumed
83
+
84
+ @consumed = true
85
+ if @tempfile
86
+ while (chunk = @tempfile.read(CHUNK_SIZE))
87
+ yield chunk
88
+ end
89
+ else
90
+ Transport.each_chunk(@response, &block)
91
+ end
92
+ nil
93
+ end
94
+
95
+ # Reads the whole file into memory.
96
+ #
97
+ # @return [String] binary bytes
98
+ def read
99
+ buffer = String.new(encoding: Encoding::BINARY)
100
+ each_chunk { |chunk| buffer << chunk }
101
+ buffer
102
+ end
103
+
104
+ # Writes the file to a path or an IO without holding it in memory.
105
+ #
106
+ # @param target [String, Pathname, #write] a path to create or overwrite, or an open IO
107
+ # @return [String, Pathname, #write] +target+
108
+ def save(target)
109
+ if target.respond_to?(:write)
110
+ each_chunk { |chunk| target.write(chunk) }
111
+ else
112
+ File.open(target, "wb") { |file| each_chunk { |chunk| file.write(chunk) } }
113
+ end
114
+ target
115
+ end
116
+
117
+ # @return [String, nil] the path of the buffered copy, when the file was captured without a block
118
+ def path
119
+ @tempfile&.path
120
+ end
121
+
122
+ # Deletes the buffered copy, if any.
123
+ #
124
+ # @return [void]
125
+ def close
126
+ @tempfile&.close!
127
+ end
128
+
129
+ private
130
+
131
+ def read_metadata(response)
132
+ header = ->(name) { response["X-InstantSnap-#{name}"] }
133
+ @id = header["Id"]
134
+ @status = header["Status"]
135
+ @content_type = response["Content-Type"]
136
+ @filename = self.class.filename_from(response["Content-Disposition"])
137
+ @width, @height, @size_bytes, @duration_ms, @page_http_status, @part_count =
138
+ %w[Width Height Size-Bytes Duration-Ms Page-Http-Status Part-Count].map { integer(header[_1]) }
139
+ @spec_version = header["Spec-Version"]
140
+ @captured_at = Model.time(header["Captured-At"])
141
+ @final_url = header["Final-Url"]
142
+ @sha256 = header["Sha256"]
143
+ end
144
+
145
+ def integer(value)
146
+ value && Integer(value, 10, exception: false)
147
+ end
148
+
149
+ class << self
150
+ # @api private
151
+ def filename_from(disposition)
152
+ return if disposition.nil?
153
+
154
+ name =
155
+ if (encoded = disposition[/filename\*\s*=\s*UTF-8''([^;\s]+)/i, 1])
156
+ URI.decode_uri_component(encoded)
157
+ elsif (quoted = disposition[/filename\s*=\s*"((?:\\.|[^"\\])*)"/i, 1])
158
+ quoted.gsub(/\\(.)/, '\1')
159
+ else
160
+ disposition[/filename\s*=\s*([^;\s]+)/i, 1]
161
+ end
162
+ # The name is the server's suggestion; never let it point outside the caller's directory.
163
+ name && File.basename(name)
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebsiteScreenshotWorld
4
+ # What the +emulated_device+ and +proxy: { country: }+ options accept, as returned by
5
+ # {InstantSnap#catalog}.
6
+ #
7
+ # @!attribute [r] emulated_devices
8
+ # @return [Array<String>]
9
+ # @!attribute [r] proxy_countries
10
+ # @return [Array<String>]
11
+ Catalog = Data.define(:emulated_devices, :proxy_countries) do
12
+ include Model::Raw
13
+
14
+ # @api private
15
+ def self.from_api(hash)
16
+ Model.build(self, hash)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebsiteScreenshotWorld
4
+ # One client covers both products. Either key may be left out; calling a product whose key is missing
5
+ # raises {AuthenticationError} before anything is sent. A client holds no connection and can be shared
6
+ # between threads.
7
+ #
8
+ # @example
9
+ # client = WebsiteScreenshotWorld::Client.new
10
+ # client.instant_snap.capture(url: "https://example.com")
11
+ # client.auto_schedule.trigger_now("7d4e2b1a-6c5f-4a3e-9d8c-2b1a0f9e8d7c")
12
+ class Client
13
+ DEFAULT_INSTANT_SNAP_BASE_URL = "https://api.websitescreenshotworld.com"
14
+ DEFAULT_AUTO_SCHEDULE_BASE_URL = "https://autoapi.websitescreenshotworld.com"
15
+
16
+ # @return [InstantSnap]
17
+ attr_reader :instant_snap
18
+
19
+ # @return [AutoSchedule]
20
+ attr_reader :auto_schedule
21
+
22
+ # @param instant_snap_api_key [String, nil] defaults to +ENV["WSW_INSTANT_SNAP_API_KEY"]+
23
+ # @param auto_schedule_api_key [String, nil] defaults to +ENV["WSW_AUTO_SCHEDULE_API_KEY"]+
24
+ # @param instant_snap_base_url [String]
25
+ # @param auto_schedule_base_url [String]
26
+ # @param capture_timeout [Numeric] seconds a capture or download may take, retries included; the
27
+ # server may spend up to 120 seconds on a capture
28
+ # @param timeout [Numeric] seconds any other call may take, retries included
29
+ # @param max_retries [Integer] retries after the first attempt for refusals the API marks as
30
+ # retryable and for connection failures; 0 turns retrying off
31
+ def initialize(
32
+ instant_snap_api_key: ENV.fetch("WSW_INSTANT_SNAP_API_KEY", nil),
33
+ auto_schedule_api_key: ENV.fetch("WSW_AUTO_SCHEDULE_API_KEY", nil),
34
+ instant_snap_base_url: DEFAULT_INSTANT_SNAP_BASE_URL,
35
+ auto_schedule_base_url: DEFAULT_AUTO_SCHEDULE_BASE_URL,
36
+ capture_timeout: 180,
37
+ timeout: 30,
38
+ max_retries: 2
39
+ )
40
+ transport = Transport.new(max_retries: max_retries)
41
+ @instant_snap = InstantSnap.new(transport: transport, base_url: instant_snap_base_url,
42
+ api_key: instant_snap_api_key, timeout: timeout,
43
+ capture_timeout: capture_timeout)
44
+ @auto_schedule = AutoSchedule.new(transport: transport, base_url: auto_schedule_base_url,
45
+ api_key: auto_schedule_api_key, timeout: timeout)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebsiteScreenshotWorld
4
+ # A viewport in CSS pixels.
5
+ #
6
+ # @!attribute [r] width
7
+ # @return [Integer]
8
+ # @!attribute [r] height
9
+ # @return [Integer]
10
+ # @!attribute [r] device_scale_factor
11
+ # @return [Numeric] device pixel ratio
12
+ Viewport = Data.define(:width, :height, :device_scale_factor) do
13
+ include Model::Raw
14
+
15
+ # @api private
16
+ def self.from_api(hash)
17
+ Model.build(self, hash)
18
+ end
19
+ end
20
+
21
+ # The values a capture takes for options it does not send, and the limits it is checked against,
22
+ # as returned by {InstantSnap#defaults}. Fields the server leaves out are nil.
23
+ #
24
+ # @!attribute [r] spec_version
25
+ # @return [String]
26
+ # @!attribute [r] response_type
27
+ # @return [String]
28
+ # @!attribute [r] media_category
29
+ # @return [String]
30
+ # @!attribute [r] format
31
+ # @return [Hash{String => String}, nil] the default format of each media category
32
+ # @!attribute [r] formats
33
+ # @return [Hash{String => Array<String>}, nil] the formats each media category accepts
34
+ # @!attribute [r] device_category
35
+ # @return [String, nil]
36
+ # @!attribute [r] emulated_device
37
+ # @return [String, nil]
38
+ # @!attribute [r] viewport
39
+ # @return [Viewport, nil]
40
+ # @!attribute [r] zoom
41
+ # @return [Integer, nil]
42
+ # @!attribute [r] scroll_mode
43
+ # @return [String, nil]
44
+ # @!attribute [r] auto_height
45
+ # @return [Boolean, nil]
46
+ # @!attribute [r] auto_height_max_height
47
+ # @return [Integer, nil]
48
+ # @!attribute [r] auto_height_max_height_webp
49
+ # @return [Integer, nil]
50
+ # @!attribute [r] block_ads
51
+ # @return [Boolean, nil]
52
+ # @!attribute [r] block_pop_ups
53
+ # @return [Boolean, nil]
54
+ # @!attribute [r] press_escape
55
+ # @return [Boolean, nil]
56
+ # @!attribute [r] page_timeout_ms
57
+ # @return [Integer, nil]
58
+ # @!attribute [r] scrolling_delay_ms
59
+ # @return [Integer, nil]
60
+ # @!attribute [r] geolocation
61
+ # @return [Boolean, nil]
62
+ # @!attribute [r] geolocation_accuracy
63
+ # @return [Numeric, nil]
64
+ # @!attribute [r] wait_for_cloud_sync
65
+ # @return [Boolean, nil]
66
+ # @!attribute [r] timestamp_watermark
67
+ # @return [Hash, nil]
68
+ # @!attribute [r] page_source
69
+ # @return [Hash, nil]
70
+ # @!attribute [r] limits
71
+ # @return [Hash, nil]
72
+ # @!attribute [r] left_to_the_browser
73
+ # @return [Array<String>, nil]
74
+ Defaults = Data.define(
75
+ :spec_version, :response_type, :media_category, :format, :formats, :device_category, :emulated_device,
76
+ :viewport, :zoom, :scroll_mode, :auto_height, :auto_height_max_height, :auto_height_max_height_webp,
77
+ :block_ads, :block_pop_ups, :press_escape, :page_timeout_ms, :scrolling_delay_ms, :geolocation,
78
+ :geolocation_accuracy, :wait_for_cloud_sync, :timestamp_watermark, :page_source, :limits, :left_to_the_browser
79
+ ) do
80
+ include Model::Raw
81
+
82
+ # @api private
83
+ def self.from_api(hash)
84
+ Model.build(self, hash, viewport: hash["viewport"]&.then { Viewport.from_api(_1) })
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebsiteScreenshotWorld
4
+ # Turns a refusal into the matching {Error}, following the API's error table.
5
+ #
6
+ # @api private
7
+ module ErrorResponse
8
+ KINDS = {
9
+ AuthenticationError => %w[api_key_invalid],
10
+ InvalidRequestError => %w[
11
+ validation_failed s3_destination_invalid web_page_id_invalid idempotency_key_reused
12
+ sitemap_schedule_not_triggerable
13
+ ],
14
+ PlanLimitError => %w[quota_exhausted feature_not_available no_trigger_allowance forbidden],
15
+ NotFoundError => %w[capture_not_found web_page_not_found not_found],
16
+ RateLimitError => %w[rate_limited queue_full],
17
+ CaptureError => %w[capture_failed capture_timeout payload_too_large capture_expired],
18
+ ServiceError => %w[capture_busy service_unavailable internal_error request_cancelled idempotency_key_in_use]
19
+ }.flat_map { |klass, codes| codes.map { |code| [code, klass] } }.to_h.freeze
20
+
21
+ RETRYABLE = %w[rate_limited capture_busy service_unavailable idempotency_key_in_use].freeze
22
+
23
+ # A proxy or load balancer answers without the API's JSON body; the code then comes from the status.
24
+ FALLBACK_BY_STATUS = {
25
+ 400 => "validation_failed",
26
+ 401 => "api_key_invalid",
27
+ 403 => "forbidden",
28
+ 404 => "not_found",
29
+ 409 => "idempotency_key_in_use",
30
+ 413 => "payload_too_large",
31
+ 429 => "rate_limited",
32
+ 500 => "internal_error",
33
+ 502 => "service_unavailable",
34
+ 503 => "service_unavailable",
35
+ 504 => "service_unavailable"
36
+ }.freeze
37
+
38
+ module_function
39
+
40
+ # @param response [Net::HTTPResponse]
41
+ # @return [Error]
42
+ def build(response)
43
+ status = response.code.to_i
44
+ body = response.body
45
+ refusal = parse(body)
46
+ code = refusal&.fetch("error_code") || FALLBACK_BY_STATUS.fetch(status, "unknown_error")
47
+ message = refusal&.dig("error_message") || "The server answered HTTP #{status} without an error body."
48
+ retry_after = refusal&.dig("retry_after_seconds")
49
+ retry_after = retry_after_header(response["Retry-After"]) unless retry_after.is_a?(Numeric)
50
+
51
+ KINDS.fetch(code, Error).new(
52
+ message, error_code: code, http_status: status, retry_after_seconds: retry_after, body: body
53
+ )
54
+ end
55
+
56
+ # @param error_code [String, nil]
57
+ def retryable?(error_code)
58
+ RETRYABLE.include?(error_code)
59
+ end
60
+
61
+ def parse(body)
62
+ refusal = Model.parse_json(body.to_s)
63
+ refusal if refusal.is_a?(Hash) && refusal["error_code"].is_a?(String)
64
+ rescue JSON::ParserError
65
+ nil
66
+ end
67
+
68
+ def retry_after_header(value)
69
+ return if value.nil?
70
+
71
+ Integer(value, exception: false) || [(Time.httpdate(value) - Time.now).ceil, 0].max
72
+ rescue ArgumentError
73
+ nil
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebsiteScreenshotWorld
4
+ # Base class of every error the library raises. Rescue a subclass to handle one kind of refusal, and
5
+ # switch on {#error_code} rather than on the status or the message.
6
+ class Error < StandardError
7
+ # @return [String, nil] the API's stable error token, such as +"rate_limited"+
8
+ attr_reader :error_code
9
+
10
+ # @return [Integer, nil] the HTTP status of the refusal
11
+ attr_reader :http_status
12
+
13
+ # @return [Integer, nil] how long the API asked to wait before retrying
14
+ attr_reader :retry_after_seconds
15
+
16
+ # @return [String, nil] the response body exactly as received
17
+ attr_reader :body
18
+
19
+ # @param message [String]
20
+ # @param error_code [String, nil]
21
+ # @param http_status [Integer, nil]
22
+ # @param retry_after_seconds [Integer, nil]
23
+ # @param body [String, nil]
24
+ def initialize(message = nil, error_code: nil, http_status: nil, retry_after_seconds: nil, body: nil)
25
+ super(message)
26
+ @error_code = error_code
27
+ @http_status = http_status
28
+ @retry_after_seconds = retry_after_seconds
29
+ @body = body
30
+ end
31
+ end
32
+
33
+ # The API key is missing, malformed, revoked or belongs to the other product.
34
+ class AuthenticationError < Error; end
35
+
36
+ # The request was refused as invalid, for example a +validation_failed+ or +idempotency_key_reused+.
37
+ class InvalidRequestError < Error; end
38
+
39
+ # The plan has no captures left or does not include the feature.
40
+ class PlanLimitError < Error; end
41
+
42
+ # The capture or web page does not exist for this account.
43
+ class NotFoundError < Error; end
44
+
45
+ # Too many requests. +rate_limited+ is retried automatically before it is raised. +queue_full+ (as many
46
+ # async captures waiting or running as the organization may have) is not retried; wait
47
+ # {#retry_after_seconds} before submitting again.
48
+ class RateLimitError < Error; end
49
+
50
+ # The website could not be captured (+capture_failed+, +capture_timeout+, +payload_too_large+), or an async
51
+ # capture waited too long in the queue (+capture_expired+). Not retried.
52
+ class CaptureError < Error; end
53
+
54
+ # The service is busy or unavailable. The retryable codes are retried automatically before it is raised.
55
+ class ServiceError < Error; end
56
+
57
+ # No answer came back: the connection failed, broke, or the timeout ran out. The original exception is
58
+ # available as +cause+.
59
+ class ConnectionError < Error; end
60
+
61
+ # A webhook's +X-WSW-Signature+ header is missing, malformed, too old, or does not match the body.
62
+ class SignatureVerificationError < Error; end
63
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebsiteScreenshotWorld
4
+ # One destination of an Auto Schedule delivery, in a +delivery.completed+ event.
5
+ #
6
+ # @!attribute [r] slot
7
+ # @return [Integer]
8
+ # @!attribute [r] name
9
+ # @return [String]
10
+ # @!attribute [r] provider
11
+ # @return [String]
12
+ # @!attribute [r] status
13
+ # @return [String]
14
+ # @!attribute [r] completed_at
15
+ # @return [Time, nil]
16
+ Destination = Data.define(:slot, :name, :provider, :status, :completed_at) do
17
+ include Model::Raw
18
+
19
+ # @api private
20
+ def self.from_api(hash)
21
+ Model.build(self, hash, completed_at: Model.time(hash["completed_at"]))
22
+ end
23
+ end
24
+
25
+ # An Auto Schedule capture, the +data+ of its +capture.succeeded+, +capture.failed+,
26
+ # +delivery.completed+ and +capture.changed+ events.
27
+ #
28
+ # @!attribute [r] id
29
+ # @return [String]
30
+ # @!attribute [r] schedule_id
31
+ # @return [String] the web page's id, the one {AutoSchedule#trigger_now} takes
32
+ # @!attribute [r] schedule_name
33
+ # @return [String]
34
+ # @!attribute [r] url
35
+ # @return [String]
36
+ # @!attribute [r] status
37
+ # @return [String]
38
+ # @!attribute [r] media_category
39
+ # @return [String]
40
+ # @!attribute [r] format
41
+ # @return [String, nil]
42
+ # @!attribute [r] page_http_status
43
+ # @return [Integer, nil]
44
+ # @!attribute [r] captured_at
45
+ # @return [Time, nil]
46
+ # @!attribute [r] final_url
47
+ # @return [String, nil]
48
+ # @!attribute [r] media_sha256
49
+ # @return [String, nil]
50
+ # @!attribute [r] page_source
51
+ # @return [PageSource, nil] with +format+ and +sha256+ only
52
+ # @!attribute [r] sitemap_run_id
53
+ # @return [String, nil]
54
+ # @!attribute [r] destinations
55
+ # @return [Array<Destination>, nil] on +delivery.completed+ only
56
+ # @!attribute [r] change_percent
57
+ # @return [Numeric, nil] on +capture.changed+ only
58
+ # @!attribute [r] compared_to_capture_id
59
+ # @return [String, nil] on +capture.changed+ only
60
+ # @!attribute [r] change_image_available
61
+ # @return [Boolean, nil] on +capture.changed+ only
62
+ ScheduledCapture = Data.define(
63
+ :id, :schedule_id, :schedule_name, :url, :status, :media_category, :format, :page_http_status,
64
+ :captured_at, :final_url, :media_sha256, :page_source, :sitemap_run_id, :destinations, :change_percent,
65
+ :compared_to_capture_id, :change_image_available
66
+ ) do
67
+ include Model::Raw
68
+
69
+ # @api private
70
+ def self.from_api(hash)
71
+ Model.build(
72
+ self, hash,
73
+ captured_at: Model.time(hash["captured_at"]),
74
+ page_source: hash["page_source"]&.then { PageSource.from_api(_1) },
75
+ destinations: hash["destinations"]&.map { Destination.from_api(_1) }
76
+ )
77
+ end
78
+ end
79
+
80
+ # The +data+ of a +schedule.paused+ event.
81
+ #
82
+ # @!attribute [r] schedule_id
83
+ # @return [String]
84
+ # @!attribute [r] schedule_name
85
+ # @return [String]
86
+ # @!attribute [r] url
87
+ # @return [String]
88
+ # @!attribute [r] reason
89
+ # @return [String] such as +quota_exhausted+
90
+ # @!attribute [r] paused_at
91
+ # @return [Time, nil]
92
+ SchedulePause = Data.define(:schedule_id, :schedule_name, :url, :reason, :paused_at) do
93
+ include Model::Raw
94
+
95
+ # @api private
96
+ def self.from_api(hash)
97
+ Model.build(self, hash, paused_at: Model.time(hash["paused_at"]))
98
+ end
99
+ end
100
+
101
+ # The +data+ of a +sitemap_run.completed+ event.
102
+ #
103
+ # @!attribute [r] sitemap_run_id
104
+ # @return [String]
105
+ # @!attribute [r] schedule_id
106
+ # @return [String]
107
+ # @!attribute [r] schedule_name
108
+ # @return [String]
109
+ # @!attribute [r] sitemap_url
110
+ # @return [String]
111
+ # @!attribute [r] status
112
+ # @return [String]
113
+ # @!attribute [r] run_at
114
+ # @return [Time, nil]
115
+ # @!attribute [r] completed_at
116
+ # @return [Time, nil]
117
+ # @!attribute [r] pages_listed
118
+ # @return [Integer, nil]
119
+ # @!attribute [r] pages_matched
120
+ # @return [Integer, nil]
121
+ # @!attribute [r] pages_queued
122
+ # @return [Integer, nil]
123
+ # @!attribute [r] pages_succeeded
124
+ # @return [Integer, nil]
125
+ # @!attribute [r] pages_failed
126
+ # @return [Integer, nil]
127
+ # @!attribute [r] pages_over_limit
128
+ # @return [Integer, nil]
129
+ # @!attribute [r] pages_over_quota
130
+ # @return [Integer, nil]
131
+ # @!attribute [r] message
132
+ # @return [String, nil]
133
+ SitemapRun = Data.define(
134
+ :sitemap_run_id, :schedule_id, :schedule_name, :sitemap_url, :status, :run_at, :completed_at,
135
+ :pages_listed, :pages_matched, :pages_queued, :pages_succeeded, :pages_failed, :pages_over_limit,
136
+ :pages_over_quota, :message
137
+ ) do
138
+ include Model::Raw
139
+
140
+ # @api private
141
+ def self.from_api(hash)
142
+ Model.build(self, hash, run_at: Model.time(hash["run_at"]), completed_at: Model.time(hash["completed_at"]))
143
+ end
144
+ end
145
+
146
+ # The +data+ of a +ping+ event, a test delivery to a webhook endpoint.
147
+ #
148
+ # @!attribute [r] endpoint_id
149
+ # @return [String]
150
+ # @!attribute [r] endpoint_name
151
+ # @return [String]
152
+ # @!attribute [r] message
153
+ # @return [String]
154
+ # @!attribute [r] sent_at
155
+ # @return [Time, nil]
156
+ Ping = Data.define(:endpoint_id, :endpoint_name, :message, :sent_at) do
157
+ include Model::Raw
158
+
159
+ # @api private
160
+ def self.from_api(hash)
161
+ Model.build(self, hash, sent_at: Model.time(hash["sent_at"]))
162
+ end
163
+ end
164
+ end