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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebsiteScreenshotWorld
|
|
4
|
+
# A webhook delivery, as returned by {Webhook.verify}. Each type has its own subclass, so a +case+ on the
|
|
5
|
+
# class (or pattern matching on +type+ and +data+) picks the handler.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] id
|
|
8
|
+
# @return [String] the delivery id
|
|
9
|
+
# @!attribute [r] type
|
|
10
|
+
# @return [String] one of {EventType}, or a type added later
|
|
11
|
+
# @!attribute [r] created_at
|
|
12
|
+
# @return [Time, nil]
|
|
13
|
+
# @!attribute [r] product
|
|
14
|
+
# @return [String] one of {Product}
|
|
15
|
+
# @!attribute [r] data
|
|
16
|
+
# @return [Object] the typed payload; see each subclass
|
|
17
|
+
Event = Data.define(:id, :type, :created_at, :product, :data) do
|
|
18
|
+
include Model::Raw
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# +capture.succeeded+. {#data} is a {Capture} for Instant Snap and a {ScheduledCapture} for Auto Schedule.
|
|
22
|
+
class CaptureSucceededEvent < Event; end
|
|
23
|
+
|
|
24
|
+
# +capture.failed+. {#data} is a {Capture} for Instant Snap and a {ScheduledCapture} for Auto Schedule.
|
|
25
|
+
class CaptureFailedEvent < Event; end
|
|
26
|
+
|
|
27
|
+
# +delivery.completed+ (Auto Schedule). {#data} is a {ScheduledCapture} with its +destinations+.
|
|
28
|
+
class DeliveryCompletedEvent < Event; end
|
|
29
|
+
|
|
30
|
+
# +capture.changed+ (Auto Schedule). {#data} is a {ScheduledCapture} with its +change_percent+.
|
|
31
|
+
class CaptureChangedEvent < Event; end
|
|
32
|
+
|
|
33
|
+
# +schedule.paused+ (Auto Schedule). {#data} is a {SchedulePause}.
|
|
34
|
+
class SchedulePausedEvent < Event; end
|
|
35
|
+
|
|
36
|
+
# +sitemap_run.completed+ (Auto Schedule). {#data} is a {SitemapRun}.
|
|
37
|
+
class SitemapRunCompletedEvent < Event; end
|
|
38
|
+
|
|
39
|
+
# +ping+, a test delivery. {#data} is a {Ping}.
|
|
40
|
+
class PingEvent < Event; end
|
|
41
|
+
|
|
42
|
+
# A type this version of the library does not know yet. {#type} names it and {#data} is the raw Hash.
|
|
43
|
+
class UnknownEvent < Event; end
|
|
44
|
+
|
|
45
|
+
# The subclasses exist only now, so the table that picks one is added here.
|
|
46
|
+
class Event
|
|
47
|
+
TYPES = {
|
|
48
|
+
EventType::DELIVERY_COMPLETED => [DeliveryCompletedEvent, ScheduledCapture],
|
|
49
|
+
EventType::CAPTURE_CHANGED => [CaptureChangedEvent, ScheduledCapture],
|
|
50
|
+
EventType::SCHEDULE_PAUSED => [SchedulePausedEvent, SchedulePause],
|
|
51
|
+
EventType::SITEMAP_RUN_COMPLETED => [SitemapRunCompletedEvent, SitemapRun],
|
|
52
|
+
EventType::PING => [PingEvent, Ping]
|
|
53
|
+
}.freeze
|
|
54
|
+
private_constant :TYPES
|
|
55
|
+
|
|
56
|
+
# @api private
|
|
57
|
+
def self.from_api(hash)
|
|
58
|
+
klass, data_class = classes_for(hash["type"], hash["product"])
|
|
59
|
+
data = hash["data"]
|
|
60
|
+
data = data_class.from_api(data) if data_class && data.is_a?(Hash)
|
|
61
|
+
Model.build(klass, hash, created_at: Model.time(hash["created_at"]), data: data)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.classes_for(type, product)
|
|
65
|
+
capture_class = product == Product::INSTANT_SNAP ? Capture : ScheduledCapture
|
|
66
|
+
case type
|
|
67
|
+
when EventType::CAPTURE_SUCCEEDED then [CaptureSucceededEvent, capture_class]
|
|
68
|
+
when EventType::CAPTURE_FAILED then [CaptureFailedEvent, capture_class]
|
|
69
|
+
else TYPES.fetch(type, [UnknownEvent, nil])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :classes_for
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebsiteScreenshotWorld
|
|
4
|
+
# The Instant Snap API: capture a page on demand. Reach it through {Client#instant_snap}.
|
|
5
|
+
#
|
|
6
|
+
# The capture and submit methods take the API's options as keyword arguments with the API's own names.
|
|
7
|
+
# Nested blocks are hashes, an option left out is not sent (an explicit +false+ is), and the keys of
|
|
8
|
+
# +headers:+ and +cookies:+ are sent exactly as given.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# client.instant_snap.capture(url: "https://example.com", format: "webp", viewport: { width: 1440 })
|
|
12
|
+
class InstantSnap < Service
|
|
13
|
+
MISSING_KEY = "No Instant Snap API key was given: pass instant_snap_api_key: to " \
|
|
14
|
+
"WebsiteScreenshotWorld::Client.new or set WSW_INSTANT_SNAP_API_KEY."
|
|
15
|
+
|
|
16
|
+
CAPTURES_PATH = "/instant-snap/v1/captures"
|
|
17
|
+
private_constant :CAPTURES_PATH
|
|
18
|
+
|
|
19
|
+
# Every option a capture accepts besides +url+. +response_type+ and +async+ are set by the method called.
|
|
20
|
+
# +timeout+ (the browser time of an async capture) is taken by {#submit} and {#submit_to_bucket} only; on
|
|
21
|
+
# the other capture methods +timeout:+ is the call's own timeout and the server refuses the option.
|
|
22
|
+
CAPTURE_OPTIONS = %i[
|
|
23
|
+
media_category format viewport device_category emulated_device scroll_mode auto_height max_height zoom
|
|
24
|
+
block_ads block_pop_ups press_escape page_timeout_ms scrolling_delay_ms user_agent locale time_zone headers
|
|
25
|
+
cookies
|
|
26
|
+
geolocation selectors proxy timestamp_watermark page_source s3_delivery callback_url timeout
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Statuses {#wait} keeps polling through.
|
|
30
|
+
UNFINISHED = [CaptureStatus::QUEUED, CaptureStatus::RUNNING, CaptureStatus::PENDING].freeze
|
|
31
|
+
private_constant :UNFINISHED
|
|
32
|
+
|
|
33
|
+
# @api private
|
|
34
|
+
def initialize(capture_timeout:, **settings)
|
|
35
|
+
super(**settings)
|
|
36
|
+
@capture_timeout = capture_timeout
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Captures a page and answers with its metadata and a download link valid for 60 minutes.
|
|
40
|
+
#
|
|
41
|
+
# @param url [String] the page to capture, absolute http or https
|
|
42
|
+
# @param idempotency_key [String, nil] makes the call safe to repeat; a random one is used otherwise
|
|
43
|
+
# @param timeout [Numeric, nil] seconds to wait for the answer, retries included; defaults to the
|
|
44
|
+
# client's +capture_timeout+
|
|
45
|
+
# @param options [Hash] any of {CAPTURE_OPTIONS}
|
|
46
|
+
# @option options [String] :media_category one of {MediaCategory}
|
|
47
|
+
# @option options [String] :format one of {Format}, belonging to the media category
|
|
48
|
+
# @option options [Hash] :viewport +width+, +height+, +device_scale_factor+
|
|
49
|
+
# @option options [String] :device_category one of {DeviceCategory}
|
|
50
|
+
# @option options [String] :emulated_device a device from {#catalog}
|
|
51
|
+
# @option options [String] :scroll_mode one of {ScrollMode}
|
|
52
|
+
# @option options [Boolean] :auto_height
|
|
53
|
+
# @option options [Integer] :max_height
|
|
54
|
+
# @option options [Integer] :zoom percent, 50 to 100
|
|
55
|
+
# @option options [Boolean] :block_ads
|
|
56
|
+
# @option options [Boolean] :block_pop_ups
|
|
57
|
+
# @option options [Boolean] :press_escape press Escape after the pop-up pass and again after scrolling,
|
|
58
|
+
# for a modal that closes on Escape
|
|
59
|
+
# @option options [Integer] :page_timeout_ms
|
|
60
|
+
# @option options [Integer] :scrolling_delay_ms the pause after each scroll step, 0 to 10000; default 500
|
|
61
|
+
# @option options [String] :user_agent
|
|
62
|
+
# @option options [String] :locale BCP 47
|
|
63
|
+
# @option options [String] :time_zone IANA time zone
|
|
64
|
+
# @option options [Hash{String => String}] :headers sent with every request the page makes
|
|
65
|
+
# @option options [Hash{String => String}] :cookies set for the URL's origin
|
|
66
|
+
# @option options [Hash] :geolocation +latitude+, +longitude+, +accuracy+
|
|
67
|
+
# @option options [Hash] :selectors +xpath+, +click+, +hover+ (each at most 512 characters) and +hide+
|
|
68
|
+
# (up to 25 comma-separated CSS selectors, at most 2000 characters)
|
|
69
|
+
# @option options [Hash] :proxy +country+, from {#catalog}
|
|
70
|
+
# @option options [Hash] :timestamp_watermark +enabled+, +location+, +font_color+, +background_color+
|
|
71
|
+
# @option options [Hash] :page_source +format+, one of {PageSourceFormat}
|
|
72
|
+
# @option options [String] :callback_url also receives the capture's webhook
|
|
73
|
+
# @return [Capture]
|
|
74
|
+
# @raise [Error] a subclass matching the refusal
|
|
75
|
+
def capture(url:, idempotency_key: nil, timeout: nil, **options)
|
|
76
|
+
hash, response = post_capture(ResponseType::JSON, url, options, idempotency_key, timeout)
|
|
77
|
+
Capture.from_api(hash, idempotent_replayed: replayed?(response))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Captures a page and uploads the file to your S3-compatible bucket.
|
|
81
|
+
#
|
|
82
|
+
# @param s3_delivery [Hash] +access_key_id+, +secret_access_key+, +bucket+, and +region+ or
|
|
83
|
+
# +endpoint_url+; optionally +force_path_style+, +key_prefix+, +wait_for_sync+
|
|
84
|
+
# @param (see #capture)
|
|
85
|
+
# @option (see #capture)
|
|
86
|
+
# @return [Capture] with {Capture#s3_delivery} describing the upload
|
|
87
|
+
# @raise [Error] a subclass matching the refusal
|
|
88
|
+
def capture_to_bucket(url:, s3_delivery:, idempotency_key: nil, timeout: nil, **options)
|
|
89
|
+
options = options.merge(s3_delivery: s3_delivery)
|
|
90
|
+
hash, response = post_capture(ResponseType::CLOUD_STORAGE, url, options, idempotency_key, timeout)
|
|
91
|
+
Capture.from_api(hash, idempotent_replayed: replayed?(response))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Captures a page and answers with the file itself, up to 64 MB.
|
|
95
|
+
#
|
|
96
|
+
# With a block, yields a {CaptureFile} whose body streams from the connection, and returns the block's
|
|
97
|
+
# value. Without one, buffers the body to a +Tempfile+ and returns the {CaptureFile}; call
|
|
98
|
+
# {CaptureFile#close} when done with it.
|
|
99
|
+
#
|
|
100
|
+
# @example
|
|
101
|
+
# client.instant_snap.capture_file(url: "https://example.com") { |file| file.save("page.png") }
|
|
102
|
+
#
|
|
103
|
+
# @param (see #capture)
|
|
104
|
+
# @option (see #capture)
|
|
105
|
+
# @yieldparam file [CaptureFile]
|
|
106
|
+
# @return [CaptureFile, Object] the file, or the block's value
|
|
107
|
+
# @raise [Error] a subclass matching the refusal
|
|
108
|
+
def capture_file(url:, idempotency_key: nil, timeout: nil, **options)
|
|
109
|
+
send_request(:post, CAPTURES_PATH,
|
|
110
|
+
body: capture_body(ResponseType::BY_FORMAT, url, options),
|
|
111
|
+
headers: capture_headers(idempotency_key),
|
|
112
|
+
accept: "*/*",
|
|
113
|
+
timeout: timeout || @capture_timeout) do |response|
|
|
114
|
+
block_given? ? yield(CaptureFile.new(response)) : CaptureFile.buffered(response)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Submits an async capture and answers at once, before it runs, with the capture in status +queued+.
|
|
119
|
+
# Follow it with {#wait}, {#get} or the +capture.succeeded+ / +capture.failed+ webhooks. A capture still
|
|
120
|
+
# queued 15 minutes after it was accepted ends +failed+ with +capture_expired+ and is not charged.
|
|
121
|
+
#
|
|
122
|
+
# The request uses the client's ordinary +timeout+ (30 seconds by default), not +capture_timeout+, since
|
|
123
|
+
# nothing runs while it waits.
|
|
124
|
+
#
|
|
125
|
+
# @example
|
|
126
|
+
# queued = client.instant_snap.submit(url: "https://example.com", timeout: 240)
|
|
127
|
+
# capture = client.instant_snap.wait(queued)
|
|
128
|
+
#
|
|
129
|
+
# @param url [String] the page to capture, absolute http or https
|
|
130
|
+
# @param timeout [Integer, nil] seconds of browser time the capture may use, 30 to 300; the server uses
|
|
131
|
+
# 120 when it is left out
|
|
132
|
+
# @param idempotency_key [String, nil] makes the call safe to repeat; a random one is used otherwise
|
|
133
|
+
# @param options [Hash] any of {CAPTURE_OPTIONS}, as for {#capture}
|
|
134
|
+
# @option (see #capture)
|
|
135
|
+
# @return [Capture] with status +queued+
|
|
136
|
+
# @raise [RateLimitError] +queue_full+ when the organization already has as many async captures waiting or
|
|
137
|
+
# running as it may; not retried, see {Error#retry_after_seconds}
|
|
138
|
+
# @raise [Error] a subclass matching any other refusal
|
|
139
|
+
def submit(url:, timeout: nil, idempotency_key: nil, **options)
|
|
140
|
+
options = options.merge(timeout: timeout)
|
|
141
|
+
hash, response = post_capture(ResponseType::JSON, url, options, idempotency_key, @timeout, async: true)
|
|
142
|
+
Capture.from_api(hash, idempotent_replayed: replayed?(response))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Submits an async capture whose file is uploaded to your S3-compatible bucket, and answers at once with
|
|
146
|
+
# the capture in status +queued+. Otherwise as {#submit}.
|
|
147
|
+
#
|
|
148
|
+
# @param s3_delivery [Hash] as for {#capture_to_bucket}
|
|
149
|
+
# @param (see #submit)
|
|
150
|
+
# @option (see #capture)
|
|
151
|
+
# @return [Capture] with status +queued+
|
|
152
|
+
# @raise (see #submit)
|
|
153
|
+
def submit_to_bucket(url:, s3_delivery:, timeout: nil, idempotency_key: nil, **options)
|
|
154
|
+
options = options.merge(s3_delivery: s3_delivery, timeout: timeout)
|
|
155
|
+
hash, response = post_capture(ResponseType::CLOUD_STORAGE, url, options, idempotency_key, @timeout,
|
|
156
|
+
async: true)
|
|
157
|
+
Capture.from_api(hash, idempotent_replayed: replayed?(response))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Reads a capture with {#get} every +poll_interval_seconds+ until its status is no longer +queued+,
|
|
161
|
+
# +running+ or +pending+, and returns it.
|
|
162
|
+
#
|
|
163
|
+
# When +timeout_seconds+ runs out first, returns the capture as last read, still unfinished, rather than
|
|
164
|
+
# raising, so you can decide whether to wait again. A refusal from {#get} is raised as from {#get}.
|
|
165
|
+
#
|
|
166
|
+
# @param capture [Capture, String] the capture, or its id
|
|
167
|
+
# @param timeout_seconds [Numeric] how long to keep polling
|
|
168
|
+
# @param poll_interval_seconds [Numeric] the pause between reads; 0 does not pause
|
|
169
|
+
# @return [Capture]
|
|
170
|
+
# @raise [Error] a subclass matching a refusal of {#get}
|
|
171
|
+
def wait(capture, timeout_seconds: 360, poll_interval_seconds: 3)
|
|
172
|
+
id = capture.respond_to?(:id) ? capture.id : capture
|
|
173
|
+
deadline = clock + timeout_seconds
|
|
174
|
+
loop do
|
|
175
|
+
current = get(id)
|
|
176
|
+
remaining = deadline - clock
|
|
177
|
+
return current if !UNFINISHED.include?(current.status) || remaining <= 0
|
|
178
|
+
|
|
179
|
+
sleep([poll_interval_seconds, remaining].min) if poll_interval_seconds.positive?
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Reads a capture back. This also reissues its +download_url+.
|
|
184
|
+
#
|
|
185
|
+
# @param id [String]
|
|
186
|
+
# @return [Capture]
|
|
187
|
+
# @raise [NotFoundError] when there is no such capture for this account
|
|
188
|
+
def get(id)
|
|
189
|
+
hash, = request_json(:get, "/instant-snap/v1/captures/#{path_segment(id)}")
|
|
190
|
+
Capture.from_api(hash)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Downloads a capture's file (or its page source) from its +download_url+, without holding it in
|
|
194
|
+
# memory. The API key is not sent to the download host.
|
|
195
|
+
#
|
|
196
|
+
# @param capture [Capture, PageSource, String] anything with a +download_url+, or the URL itself
|
|
197
|
+
# @param target [String, Pathname, #write] a path to create or overwrite, or an open IO
|
|
198
|
+
# @param timeout [Numeric, nil] seconds for the whole download; defaults to the client's +capture_timeout+
|
|
199
|
+
# @return [String, Pathname, #write] +target+
|
|
200
|
+
# @raise [ArgumentError] when the capture has no +download_url+
|
|
201
|
+
def download(capture, target, timeout: nil)
|
|
202
|
+
url = capture.respond_to?(:download_url) ? capture.download_url : capture
|
|
203
|
+
raise ArgumentError, "this capture has no download_url; read it again with #get to reissue one" unless url
|
|
204
|
+
|
|
205
|
+
@transport.request(:get, url, headers: { "Accept" => "*/*" }, timeout: timeout || @capture_timeout) do |answer|
|
|
206
|
+
Transport.write_body(answer, target)
|
|
207
|
+
end
|
|
208
|
+
target
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The values a capture takes for options it does not send, and the limits it is checked against.
|
|
212
|
+
# Needs no API key.
|
|
213
|
+
#
|
|
214
|
+
# @return [Defaults]
|
|
215
|
+
def defaults
|
|
216
|
+
hash, = request_json(:get, "/instant-snap/v1/defaults", authenticate: false)
|
|
217
|
+
Defaults.from_api(hash)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The devices +emulated_device+ accepts and the countries +proxy: { country: }+ accepts.
|
|
221
|
+
#
|
|
222
|
+
# @return [Catalog]
|
|
223
|
+
def catalog
|
|
224
|
+
hash, = request_json(:get, "/instant-snap/v1/catalog")
|
|
225
|
+
Catalog.from_api(hash)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def post_capture(response_type, url, options, idempotency_key, timeout, async: false)
|
|
231
|
+
request_json(:post, CAPTURES_PATH,
|
|
232
|
+
body: capture_body(response_type, url, options, async: async),
|
|
233
|
+
headers: capture_headers(idempotency_key),
|
|
234
|
+
timeout: timeout || @capture_timeout)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def capture_body(response_type, url, options, async: false)
|
|
238
|
+
unknown = options.keys - CAPTURE_OPTIONS
|
|
239
|
+
unless unknown.empty?
|
|
240
|
+
raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
body = without_nils(url: url, **options).merge(response_type: response_type)
|
|
244
|
+
async ? body.merge(async: true) : body
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# One key per call, reused by that call's retries, so a retry never captures twice.
|
|
248
|
+
def capture_headers(idempotency_key)
|
|
249
|
+
{ "Idempotency-Key" => idempotency_key || SecureRandom.uuid }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def clock
|
|
253
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def replayed?(response)
|
|
257
|
+
response["Idempotent-Replayed"] == "true"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebsiteScreenshotWorld
|
|
4
|
+
# Helpers the result types share.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module Model
|
|
8
|
+
# Gives a +Data+ class a {#raw} reader holding the object as the API sent it.
|
|
9
|
+
module Raw
|
|
10
|
+
# @return [Hash{String => Object}] the object as the API sent it, including fields this version of the
|
|
11
|
+
# library does not know yet
|
|
12
|
+
attr_reader :raw
|
|
13
|
+
|
|
14
|
+
def initialize(raw: {}.freeze, **fields)
|
|
15
|
+
@raw = raw
|
|
16
|
+
super(**fields)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Builds +klass+ from an API hash, taking each member from the key of the same name. Keys the class
|
|
23
|
+
# does not know are left in +raw+ only; +converted+ replaces members that need more than a copy.
|
|
24
|
+
def build(klass, hash, **converted)
|
|
25
|
+
fields = klass.members.to_h { |name| [name, hash[name.to_s]] }
|
|
26
|
+
klass.new(raw: hash, **fields, **converted)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Parses a JSON body into frozen objects. The API always sends UTF-8, whatever encoding Ruby tagged the
|
|
30
|
+
# bytes with.
|
|
31
|
+
def parse_json(bytes)
|
|
32
|
+
JSON.parse(String.new(bytes, encoding: Encoding::UTF_8), freeze: true)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Parses an ISO 8601 timestamp (any fraction length, +Z+ or an offset) into a UTC +Time+.
|
|
36
|
+
#
|
|
37
|
+
# @return [Time, nil] nil when the value is absent or not a timestamp
|
|
38
|
+
def time(value)
|
|
39
|
+
return value if value.nil? || value.is_a?(Time)
|
|
40
|
+
|
|
41
|
+
Time.iso8601(value.to_s).utc
|
|
42
|
+
rescue ArgumentError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebsiteScreenshotWorld
|
|
4
|
+
# What {InstantSnap} and {AutoSchedule} share: the key, the base URL and JSON requests.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class Service
|
|
8
|
+
def initialize(transport:, base_url:, api_key:, timeout:)
|
|
9
|
+
@transport = transport
|
|
10
|
+
@base_url = base_url.to_s.chomp("/")
|
|
11
|
+
@api_key = api_key.to_s.empty? ? nil : api_key
|
|
12
|
+
@timeout = timeout
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Keeps the API key out of logs and consoles.
|
|
16
|
+
def inspect
|
|
17
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect}>"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Sends a call to this product and yields the successful response with its body unread.
|
|
23
|
+
def send_request(method, path, body: nil, headers: {}, timeout: @timeout, authenticate: true,
|
|
24
|
+
accept: "application/json", &)
|
|
25
|
+
headers = { "Accept" => accept }.merge(headers)
|
|
26
|
+
headers["Content-Type"] = "application/json" if body
|
|
27
|
+
headers["X-Api-Key"] = api_key! if authenticate
|
|
28
|
+
@transport.request(method, "#{@base_url}#{path}", headers: headers, body: body && JSON.generate(body),
|
|
29
|
+
timeout: timeout, &)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Array(Hash, Net::HTTPResponse)] the answer without +success+, and the response for its headers
|
|
33
|
+
def request_json(method, path, **options)
|
|
34
|
+
response = send_request(method, path, **options) { |answer| answer.tap(&:read_body) }
|
|
35
|
+
[parse(response), response]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def api_key!
|
|
39
|
+
@api_key || raise(AuthenticationError, self.class::MISSING_KEY)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse(response)
|
|
43
|
+
hash = Model.parse_json(response.body)
|
|
44
|
+
raise JSON::ParserError, "expected an object" unless hash.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
hash.except("success").freeze
|
|
47
|
+
rescue JSON::ParserError
|
|
48
|
+
raise Error.new("The server answered HTTP #{response.code} with a body that is not a JSON object.",
|
|
49
|
+
http_status: response.code.to_i, body: response.body)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def path_segment(value)
|
|
53
|
+
URI.encode_uri_component(value.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Drops the options that were not set, at every level; +false+, +0+ and +""+ stay.
|
|
57
|
+
def without_nils(hash)
|
|
58
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
59
|
+
next if value.nil?
|
|
60
|
+
|
|
61
|
+
result[key] = value.is_a?(Hash) ? without_nils(value) : value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebsiteScreenshotWorld
|
|
4
|
+
# Values of +status+ on a {Capture}. Compare with these rather than literals; a value added later still
|
|
5
|
+
# comes through as a plain String.
|
|
6
|
+
module CaptureStatus
|
|
7
|
+
# An async capture was accepted and is waiting for a browser.
|
|
8
|
+
QUEUED = "queued"
|
|
9
|
+
# A browser is taking an async capture.
|
|
10
|
+
RUNNING = "running"
|
|
11
|
+
PENDING = "pending"
|
|
12
|
+
SUCCEEDED = "succeeded"
|
|
13
|
+
# The end state of a +by_format+ capture: the file went back in the response and is not kept.
|
|
14
|
+
DELIVERED = "delivered"
|
|
15
|
+
FAILED = "failed"
|
|
16
|
+
TIMED_OUT = "timed_out"
|
|
17
|
+
EXPIRED = "expired"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Values of +response_type+. The capture methods set it themselves.
|
|
21
|
+
module ResponseType
|
|
22
|
+
JSON = "json"
|
|
23
|
+
BY_FORMAT = "by_format"
|
|
24
|
+
CLOUD_STORAGE = "cloud_storage"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Values of the +media_category+ option and field.
|
|
28
|
+
module MediaCategory
|
|
29
|
+
IMAGE = "image"
|
|
30
|
+
VIDEO = "video"
|
|
31
|
+
PDF = "pdf"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Values of the +format+ option and field. Each belongs to one {MediaCategory}.
|
|
35
|
+
module Format
|
|
36
|
+
PNG = "png"
|
|
37
|
+
JPEG = "jpeg"
|
|
38
|
+
WEBP = "webp"
|
|
39
|
+
TIFF = "tiff"
|
|
40
|
+
MP4 = "mp4"
|
|
41
|
+
WEBM = "webm"
|
|
42
|
+
MOV = "mov"
|
|
43
|
+
GIF = "gif"
|
|
44
|
+
IMAGE_PDF = "image_pdf"
|
|
45
|
+
DOCUMENT_PDF = "document_pdf"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Values of the +device_category+ option.
|
|
49
|
+
module DeviceCategory
|
|
50
|
+
DESKTOP = "desktop"
|
|
51
|
+
MOBILE = "mobile"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Values of the +scroll_mode+ option. +SCROLL+ captures the full page.
|
|
55
|
+
module ScrollMode
|
|
56
|
+
VIEWPORT = "viewport"
|
|
57
|
+
SCROLL = "scroll"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Values of +timestamp_watermark: { location: }+.
|
|
61
|
+
module WatermarkLocation
|
|
62
|
+
TOP = "top"
|
|
63
|
+
BOTTOM = "bottom"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Values of +page_source: { format: }+ and {PageSource#format}.
|
|
67
|
+
module PageSourceFormat
|
|
68
|
+
HTML = "html"
|
|
69
|
+
JSON = "json"
|
|
70
|
+
TEXT = "text"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Values of {S3Delivery#status}.
|
|
74
|
+
module S3DeliveryStatus
|
|
75
|
+
NOT_STARTED = "not_started"
|
|
76
|
+
IN_PROGRESS = "in_progress"
|
|
77
|
+
COMPLETED = "completed"
|
|
78
|
+
FAILED = "failed"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Values of {TriggerResult#status}.
|
|
82
|
+
module TriggerStatus
|
|
83
|
+
TRIGGERED = "triggered"
|
|
84
|
+
# A capture for this web page was already queued or running: nothing new started, nothing was charged.
|
|
85
|
+
ALREADY_PENDING = "already_pending"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Values of {TriggerResult#triggered_by}.
|
|
89
|
+
module TriggeredBy
|
|
90
|
+
API = "api"
|
|
91
|
+
UI = "ui"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Values of {Event#product}.
|
|
95
|
+
module Product
|
|
96
|
+
INSTANT_SNAP = "instant_snap"
|
|
97
|
+
AUTO_SCHEDULE = "auto_schedule"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Values of {Event#type}.
|
|
101
|
+
module EventType
|
|
102
|
+
CAPTURE_SUCCEEDED = "capture.succeeded"
|
|
103
|
+
CAPTURE_FAILED = "capture.failed"
|
|
104
|
+
DELIVERY_COMPLETED = "delivery.completed"
|
|
105
|
+
CAPTURE_CHANGED = "capture.changed"
|
|
106
|
+
SCHEDULE_PAUSED = "schedule.paused"
|
|
107
|
+
SITEMAP_RUN_COMPLETED = "sitemap_run.completed"
|
|
108
|
+
PING = "ping"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebsiteScreenshotWorld
|
|
4
|
+
# Sends one call over Net::HTTP, retrying what the API marks as retryable.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class Transport
|
|
8
|
+
USER_AGENT = "websitescreenshotworld-ruby/#{VERSION} (#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION})".freeze
|
|
9
|
+
|
|
10
|
+
CONNECTION_ERRORS = [
|
|
11
|
+
IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError,
|
|
12
|
+
Net::HTTPBadResponse, Net::ProtocolError
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
MAX_RETRY_AFTER = 60
|
|
16
|
+
FIRST_BACKOFF = 0.5
|
|
17
|
+
JITTER = 0.25
|
|
18
|
+
|
|
19
|
+
# Net::HTTP gives every POST a form Content-Type, even an empty one. Trigger-now has no body at all.
|
|
20
|
+
class EmptyPost < Net::HTTPRequest
|
|
21
|
+
METHOD = "POST"
|
|
22
|
+
REQUEST_HAS_BODY = false
|
|
23
|
+
RESPONSE_HAS_BODY = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Reads a response body chunk by chunk. A network failure becomes a {ConnectionError}; an exception
|
|
27
|
+
# raised by the block passes through untouched.
|
|
28
|
+
def self.each_chunk(response)
|
|
29
|
+
in_block = false
|
|
30
|
+
response.read_body do |chunk|
|
|
31
|
+
in_block = true
|
|
32
|
+
yield chunk
|
|
33
|
+
in_block = false
|
|
34
|
+
end
|
|
35
|
+
rescue *CONNECTION_ERRORS => e
|
|
36
|
+
raise if in_block
|
|
37
|
+
|
|
38
|
+
raise ConnectionError, "The connection broke before the whole body arrived (#{e.message})."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Writes a response body to an IO, or to a file it creates at a path and deletes again if the body does
|
|
42
|
+
# not arrive whole.
|
|
43
|
+
def self.write_body(response, target)
|
|
44
|
+
return each_chunk(response) { |chunk| target.write(chunk) } if target.respond_to?(:write)
|
|
45
|
+
|
|
46
|
+
File.open(target, "wb") { |file| each_chunk(response) { |chunk| file.write(chunk) } }
|
|
47
|
+
rescue StandardError
|
|
48
|
+
File.delete(target) if !target.respond_to?(:write) && File.exist?(target)
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(max_retries:)
|
|
53
|
+
@max_retries = max_retries
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sends the request, retrying refusals and connection failures until it succeeds, the retries run out
|
|
57
|
+
# or +timeout+ seconds have passed, then yields the successful response with its body still unread.
|
|
58
|
+
#
|
|
59
|
+
# @return the block's value
|
|
60
|
+
def request(method, url, headers:, timeout:, body: nil)
|
|
61
|
+
uri = URI(url)
|
|
62
|
+
deadline = clock + timeout
|
|
63
|
+
retries = 0
|
|
64
|
+
delivered = false
|
|
65
|
+
begin
|
|
66
|
+
connection(uri, deadline).start do |http|
|
|
67
|
+
http.request(build_request(method, uri, headers, body)) do |response|
|
|
68
|
+
raise ErrorResponse.build(response) unless response.is_a?(Net::HTTPSuccess)
|
|
69
|
+
|
|
70
|
+
delivered = true
|
|
71
|
+
# Returning from inside the block closes the connection instead of draining an unread body.
|
|
72
|
+
return yield(response)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
rescue Error, *CONNECTION_ERRORS => e
|
|
76
|
+
raise if delivered
|
|
77
|
+
|
|
78
|
+
error = e.is_a?(Error) ? e : ConnectionError.new("The request to #{uri.host} failed: #{e.message}")
|
|
79
|
+
delay = retry_delay(error, retries, deadline) or raise error
|
|
80
|
+
sleep(delay)
|
|
81
|
+
retries += 1
|
|
82
|
+
retry
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def connection(uri, deadline)
|
|
89
|
+
remaining = deadline - clock
|
|
90
|
+
Net::HTTP.new(uri.hostname, uri.port).tap do |http|
|
|
91
|
+
http.use_ssl = uri.scheme == "https"
|
|
92
|
+
http.open_timeout = http.read_timeout = http.write_timeout = http.ssl_timeout = remaining
|
|
93
|
+
http.max_retries = 0
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_request(method, uri, headers, body)
|
|
98
|
+
headers = headers.merge("User-Agent" => USER_AGENT)
|
|
99
|
+
case [method, body.nil?]
|
|
100
|
+
in [:get, _] then Net::HTTP::Get.new(uri, headers)
|
|
101
|
+
in [:post, true] then EmptyPost.new(uri, headers.merge("Content-Length" => "0"))
|
|
102
|
+
in [:post, false] then Net::HTTP::Post.new(uri, headers).tap { _1.body = body }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Float, nil] seconds to wait before retrying, or nil to raise the error now
|
|
107
|
+
def retry_delay(error, retries, deadline)
|
|
108
|
+
return if retries >= @max_retries
|
|
109
|
+
return unless error.is_a?(ConnectionError) || ErrorResponse.retryable?(error.error_code)
|
|
110
|
+
|
|
111
|
+
delay = error.retry_after_seconds&.clamp(0, MAX_RETRY_AFTER) || backoff(retries)
|
|
112
|
+
delay if clock + delay < deadline
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def backoff(retries)
|
|
116
|
+
base = FIRST_BACKOFF * (2**retries)
|
|
117
|
+
base + (base * JITTER * rand)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def clock
|
|
121
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|