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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +59 -0
- data/LICENSE +21 -0
- data/README.md +371 -0
- data/lib/websitescreenshotworld/auto_schedule.rb +22 -0
- data/lib/websitescreenshotworld/capture.rb +124 -0
- data/lib/websitescreenshotworld/capture_file.rb +167 -0
- data/lib/websitescreenshotworld/catalog.rb +19 -0
- data/lib/websitescreenshotworld/client.rb +48 -0
- data/lib/websitescreenshotworld/defaults.rb +87 -0
- data/lib/websitescreenshotworld/error_response.rb +76 -0
- data/lib/websitescreenshotworld/errors.rb +63 -0
- data/lib/websitescreenshotworld/event_data.rb +164 -0
- data/lib/websitescreenshotworld/events.rb +74 -0
- data/lib/websitescreenshotworld/instant_snap.rb +260 -0
- data/lib/websitescreenshotworld/model.rb +46 -0
- data/lib/websitescreenshotworld/service.rb +65 -0
- data/lib/websitescreenshotworld/tokens.rb +110 -0
- data/lib/websitescreenshotworld/transport.rb +124 -0
- data/lib/websitescreenshotworld/trigger_result.rb +24 -0
- data/lib/websitescreenshotworld/version.rb +6 -0
- data/lib/websitescreenshotworld/webhook.rb +68 -0
- data/lib/websitescreenshotworld.rb +33 -0
- metadata +69 -0
|
@@ -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
|