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,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