screenshotscout 0.1.0.rc1
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/LICENSE +21 -0
- data/README.md +286 -0
- data/examples/README.md +43 -0
- data/examples/binary.rb +12 -0
- data/examples/capture_url.rb +10 -0
- data/examples/json.rb +13 -0
- data/lib/screenshotscout/capture_options.rb +37 -0
- data/lib/screenshotscout/capture_values.rb +73 -0
- data/lib/screenshotscout/client.rb +230 -0
- data/lib/screenshotscout/errors.rb +59 -0
- data/lib/screenshotscout/internal/ecma_script_number_formatter.rb +52 -0
- data/lib/screenshotscout/internal/json_codec.rb +43 -0
- data/lib/screenshotscout/internal/serializer.rb +227 -0
- data/lib/screenshotscout/responses.rb +83 -0
- data/lib/screenshotscout/transport.rb +58 -0
- data/lib/screenshotscout/version.rb +5 -0
- data/lib/screenshotscout.rb +12 -0
- data/sig/screenshotscout.rbs +350 -0
- metadata +116 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module ScreenshotScout
|
|
6
|
+
# Reusable blocking client for the inline Screenshot Scout capture operation.
|
|
7
|
+
class Client
|
|
8
|
+
CAPTURE_ENDPOINT = "https://api.screenshotscout.com/v1/capture"
|
|
9
|
+
ACCESS_KEY_PATTERN = %r{\A[A-Za-z0-9\-._~+/]+=*\z}
|
|
10
|
+
MISSING_ACCESS_KEY = Object.new.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(access_key: MISSING_ACCESS_KEY, secret_key: nil, transport: nil)
|
|
13
|
+
validate_access_key(access_key)
|
|
14
|
+
validate_secret_key(secret_key)
|
|
15
|
+
validate_transport(transport)
|
|
16
|
+
|
|
17
|
+
@access_key = access_key.dup.freeze
|
|
18
|
+
@secret_key = secret_key&.dup&.freeze
|
|
19
|
+
@transport = transport || NetHttpTransport.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Sends one GET or POST request and waits for the final inline response.
|
|
23
|
+
def capture(url, options = nil, method: CaptureHttpMethod::POST)
|
|
24
|
+
serialized = Internal::Serializer.serialize(url, options)
|
|
25
|
+
method = CaptureHttpMethod::POST if method.nil?
|
|
26
|
+
validate_method(method)
|
|
27
|
+
expected_kind = expected_response_kind(options)
|
|
28
|
+
signature = create_signature(serialized.pairs)
|
|
29
|
+
request = create_request(serialized, method, signature)
|
|
30
|
+
raw_response = execute_request(request)
|
|
31
|
+
|
|
32
|
+
raise create_api_error(raw_response) unless raw_response.status.between?(200, 299)
|
|
33
|
+
|
|
34
|
+
decode_success_response(raw_response, expected_kind)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Builds a sensitive GET capture URL without performing network I/O.
|
|
38
|
+
def build_capture_url(url, options = nil)
|
|
39
|
+
serialized = Internal::Serializer.serialize(url, options)
|
|
40
|
+
signature = create_signature(serialized.pairs)
|
|
41
|
+
pairs = [Internal::WirePair.new(name: "access_key", value: @access_key), *serialized.pairs]
|
|
42
|
+
pairs << Internal::WirePair.new(name: "signature", value: signature) unless signature.nil?
|
|
43
|
+
"#{CAPTURE_ENDPOINT}?#{Internal::Serializer.encode_query(pairs)}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate_access_key(access_key)
|
|
49
|
+
unless access_key.is_a?(String) && !access_key.strip.empty?
|
|
50
|
+
raise ConfigurationError, "A non-blank Screenshot Scout access key is required."
|
|
51
|
+
end
|
|
52
|
+
return if ACCESS_KEY_PATTERN.match?(access_key)
|
|
53
|
+
|
|
54
|
+
raise ConfigurationError, "The Screenshot Scout access key is not a valid Bearer credential."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_secret_key(secret_key)
|
|
58
|
+
return if secret_key.nil?
|
|
59
|
+
return if secret_key.is_a?(String) && !secret_key.strip.empty?
|
|
60
|
+
|
|
61
|
+
raise ConfigurationError, "The Screenshot Scout secret key must be a non-blank string when provided."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_transport(transport)
|
|
65
|
+
return if transport.nil? || transport.respond_to?(:call)
|
|
66
|
+
|
|
67
|
+
raise ConfigurationError, "The Screenshot Scout transport must respond to #call."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_method(method)
|
|
71
|
+
return if [CaptureHttpMethod::GET, CaptureHttpMethod::POST].include?(method)
|
|
72
|
+
|
|
73
|
+
raise SerializationError.new(
|
|
74
|
+
"The capture method must be CaptureHttpMethod::GET or CaptureHttpMethod::POST.",
|
|
75
|
+
option: :method
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def create_signature(pairs)
|
|
80
|
+
return nil if @secret_key.nil?
|
|
81
|
+
|
|
82
|
+
canonical_query = Internal::Serializer.build_canonical_query(pairs, @access_key)
|
|
83
|
+
OpenSSL::HMAC.hexdigest("SHA256", @secret_key, canonical_query)
|
|
84
|
+
rescue Error
|
|
85
|
+
raise
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
raise SerializationError.new("The capture request could not be signed."), cause: e
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def create_request(serialized, method, signature)
|
|
91
|
+
headers = { "Authorization" => "Bearer #{@access_key}" }
|
|
92
|
+
if method == CaptureHttpMethod::GET
|
|
93
|
+
pairs = serialized.pairs.dup
|
|
94
|
+
pairs << Internal::WirePair.new(name: "signature", value: signature) unless signature.nil?
|
|
95
|
+
url = "#{CAPTURE_ENDPOINT}?#{Internal::Serializer.encode_query(pairs)}"
|
|
96
|
+
TransportRequest.new(method: method, url: url, headers: headers)
|
|
97
|
+
else
|
|
98
|
+
body = serialized.body.dup
|
|
99
|
+
body["signature"] = signature unless signature.nil?
|
|
100
|
+
headers["Content-Type"] = "application/json"
|
|
101
|
+
TransportRequest.new(
|
|
102
|
+
method: method,
|
|
103
|
+
url: CAPTURE_ENDPOINT,
|
|
104
|
+
headers: headers,
|
|
105
|
+
body: Internal::JsonCodec.encode_wire_object(body)
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
rescue Error
|
|
109
|
+
raise
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
raise SerializationError.new("The Screenshot Scout HTTP request could not be constructed."), cause: e
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def execute_request(request)
|
|
115
|
+
raw_response = @transport.call(request)
|
|
116
|
+
return raw_response if raw_response.is_a?(RawResponse)
|
|
117
|
+
|
|
118
|
+
raise TypeError, "The Screenshot Scout transport must return ScreenshotScout::RawResponse."
|
|
119
|
+
rescue Error
|
|
120
|
+
raise
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
raise TransportError, "Screenshot Scout request failed before a complete HTTP response body was received.",
|
|
123
|
+
cause: e
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def expected_response_kind(options)
|
|
127
|
+
response_type = options&.response_type
|
|
128
|
+
return :binary if response_type.nil? || response_type == CaptureResponseType::BINARY
|
|
129
|
+
return :json if response_type == CaptureResponseType::JSON
|
|
130
|
+
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def decode_success_response(raw_response, expected_kind)
|
|
135
|
+
actual_kind = json_media_type?(raw_response.content_type) ? :json : :binary
|
|
136
|
+
if !expected_kind.nil? && actual_kind != expected_kind
|
|
137
|
+
message = "Screenshot Scout returned a successful #{actual_kind} response " \
|
|
138
|
+
"when #{expected_kind} was requested."
|
|
139
|
+
cause = TypeError.new("Expected a #{expected_kind} response but received #{actual_kind}.")
|
|
140
|
+
raise ResponseDecodingError.new(message, raw_response), cause: cause
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
actual_kind == :json ? decode_json_response(raw_response) : decode_binary_response(raw_response)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def decode_binary_response(raw_response)
|
|
147
|
+
BinaryCaptureResponse.new(
|
|
148
|
+
raw_response: raw_response,
|
|
149
|
+
screenshot_url: raw_response.header("Screenshot-Scout-Screenshot-URL").first,
|
|
150
|
+
screenshot_url_expires_at: raw_response.header("Screenshot-Scout-Screenshot-URL-Expires-At").first,
|
|
151
|
+
cache_status: raw_response.header("Screenshot-Scout-Cache-Status").first
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def decode_json_response(raw_response)
|
|
156
|
+
value = parse_json_success(raw_response)
|
|
157
|
+
unless value.is_a?(Hash)
|
|
158
|
+
cause = TypeError.new("Expected a JSON object.")
|
|
159
|
+
raise ResponseDecodingError.new(
|
|
160
|
+
"Screenshot Scout returned a successful JSON response that was not an object.",
|
|
161
|
+
raw_response
|
|
162
|
+
), cause: cause
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
screenshot_url = read_optional_string(value, "screenshot_url", raw_response)
|
|
166
|
+
expires_at = read_optional_string(value, "screenshot_url_expires_at", raw_response)
|
|
167
|
+
cache_status = read_optional_string(value, "cache_status", raw_response)
|
|
168
|
+
additional_fields = value.except("screenshot_url", "screenshot_url_expires_at", "cache_status")
|
|
169
|
+
result = CaptureResult.new(
|
|
170
|
+
screenshot_url: screenshot_url,
|
|
171
|
+
screenshot_url_expires_at: expires_at,
|
|
172
|
+
cache_status: cache_status,
|
|
173
|
+
additional_fields: additional_fields
|
|
174
|
+
)
|
|
175
|
+
JsonCaptureResponse.new(result: result, raw_response: raw_response)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_json_success(raw_response)
|
|
179
|
+
Internal::JsonCodec.parse(raw_response.body)
|
|
180
|
+
rescue JSON::ParserError, EncodingError => e
|
|
181
|
+
raise ResponseDecodingError.new(
|
|
182
|
+
"Screenshot Scout returned a successful JSON response that could not be decoded.",
|
|
183
|
+
raw_response
|
|
184
|
+
), cause: e
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def read_optional_string(object, key, raw_response)
|
|
188
|
+
return nil unless object.key?(key)
|
|
189
|
+
return object[key] if object[key].is_a?(String)
|
|
190
|
+
|
|
191
|
+
cause = TypeError.new("Expected \"#{key}\" to be a string.")
|
|
192
|
+
raise ResponseDecodingError.new(
|
|
193
|
+
"Screenshot Scout returned a non-string \"#{key}\" JSON field.",
|
|
194
|
+
raw_response
|
|
195
|
+
), cause: cause
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def create_api_error(raw_response)
|
|
199
|
+
available, response_body = try_parse_json(raw_response.body)
|
|
200
|
+
object = response_body if response_body.is_a?(Hash)
|
|
201
|
+
error_code = object["error_code"] if object&.fetch("error_code", nil).is_a?(String)
|
|
202
|
+
error_message = object["error_message"] if object&.fetch("error_message", nil).is_a?(String)
|
|
203
|
+
errors = object["errors"] if object&.fetch("errors", nil).is_a?(Array)
|
|
204
|
+
|
|
205
|
+
APIError.new(
|
|
206
|
+
raw_response: raw_response,
|
|
207
|
+
response_body_available: available,
|
|
208
|
+
response_body: response_body,
|
|
209
|
+
error_code: error_code,
|
|
210
|
+
error_message: error_message,
|
|
211
|
+
errors: errors
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def try_parse_json(body)
|
|
216
|
+
[true, Internal::JsonCodec.parse(body)]
|
|
217
|
+
rescue JSON::ParserError, EncodingError
|
|
218
|
+
[false, nil]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def json_media_type?(content_type)
|
|
222
|
+
return false if content_type.nil?
|
|
223
|
+
|
|
224
|
+
media_type = content_type.partition(";").first.strip.downcase
|
|
225
|
+
media_type == "application/json" || media_type.end_with?("+json")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private_constant :CAPTURE_ENDPOINT, :ACCESS_KEY_PATTERN, :MISSING_ACCESS_KEY
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ScreenshotScout
|
|
4
|
+
# Base class for every exception raised by the SDK.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when client credentials or capture-call controls are unusable.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a capture option cannot be represented safely on the wire.
|
|
11
|
+
class SerializationError < Error
|
|
12
|
+
attr_reader :option
|
|
13
|
+
|
|
14
|
+
def initialize(message, option: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@option = option
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Raised when the configured transport cannot complete an HTTP exchange.
|
|
21
|
+
class TransportError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised for every non-2xx response returned by the Screenshot Scout API.
|
|
24
|
+
class APIError < Error
|
|
25
|
+
attr_reader :status, :error_code, :error_message, :errors, :response_body, :raw_response
|
|
26
|
+
|
|
27
|
+
def initialize(
|
|
28
|
+
raw_response:,
|
|
29
|
+
response_body_available:,
|
|
30
|
+
response_body: nil,
|
|
31
|
+
error_code: nil,
|
|
32
|
+
error_message: nil,
|
|
33
|
+
errors: nil
|
|
34
|
+
)
|
|
35
|
+
super(error_message || "Screenshot Scout API request failed with status #{raw_response.status}.")
|
|
36
|
+
@status = raw_response.status
|
|
37
|
+
@error_code = error_code
|
|
38
|
+
@error_message = error_message
|
|
39
|
+
@errors = errors
|
|
40
|
+
@response_body_available = response_body_available
|
|
41
|
+
@response_body = response_body
|
|
42
|
+
@raw_response = raw_response
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def response_body?
|
|
46
|
+
@response_body_available
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Raised when a successful response cannot be decoded as its expected type.
|
|
51
|
+
class ResponseDecodingError < Error
|
|
52
|
+
attr_reader :raw_response
|
|
53
|
+
|
|
54
|
+
def initialize(message, raw_response)
|
|
55
|
+
super(message)
|
|
56
|
+
@raw_response = raw_response
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ScreenshotScout
|
|
4
|
+
module Internal
|
|
5
|
+
# Formats IEEE-754 values like JavaScript String(number) and JSON.stringify.
|
|
6
|
+
module EcmaScriptNumberFormatter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def format(value)
|
|
10
|
+
raise ArgumentError, "Expected a finite floating-point value." unless value.finite?
|
|
11
|
+
return "0" if value.zero?
|
|
12
|
+
|
|
13
|
+
encoded = value.to_s.downcase
|
|
14
|
+
return normalize_decimal(encoded) unless encoded.include?("e")
|
|
15
|
+
|
|
16
|
+
match = /\A(-?)([0-9])(?:\.([0-9]+))?e([+-]?[0-9]+)\z/.match(encoded)
|
|
17
|
+
raise ArgumentError, "Unexpected floating-point encoding." if match.nil?
|
|
18
|
+
|
|
19
|
+
negative = match[1] == "-"
|
|
20
|
+
digits = (match[2] + match[3].to_s).sub(/0+\z/, "")
|
|
21
|
+
digits = "0" if digits.empty?
|
|
22
|
+
exponent = Integer(match[4], 10)
|
|
23
|
+
number = expand_digits(digits, exponent)
|
|
24
|
+
negative ? "-#{number}" : number
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def normalize_decimal(encoded)
|
|
28
|
+
return encoded unless encoded.include?(".")
|
|
29
|
+
|
|
30
|
+
encoded.sub(/0+\z/, "").sub(/\.\z/, "")
|
|
31
|
+
end
|
|
32
|
+
private_class_method :normalize_decimal
|
|
33
|
+
|
|
34
|
+
def expand_digits(digits, exponent)
|
|
35
|
+
decimal_position = exponent + 1
|
|
36
|
+
if decimal_position.positive? && decimal_position <= 21
|
|
37
|
+
if digits.length <= decimal_position
|
|
38
|
+
digits + ("0" * (decimal_position - digits.length))
|
|
39
|
+
else
|
|
40
|
+
"#{digits[0, decimal_position]}.#{digits[decimal_position..]}"
|
|
41
|
+
end
|
|
42
|
+
elsif decimal_position > -6 && decimal_position <= 0
|
|
43
|
+
"0.#{'0' * -decimal_position}#{digits}"
|
|
44
|
+
else
|
|
45
|
+
mantissa = digits.length == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
|
|
46
|
+
"#{mantissa}e#{'+' if exponent >= 0}#{exponent}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
private_class_method :expand_digits
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ScreenshotScout
|
|
6
|
+
module Internal
|
|
7
|
+
# Encodes already-validated capture values using API-compatible JSON numbers.
|
|
8
|
+
module JsonCodec
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def encode_wire_object(object)
|
|
12
|
+
members = object.map do |name, value|
|
|
13
|
+
"#{JSON.generate(name)}:#{encode_wire_value(value)}"
|
|
14
|
+
end
|
|
15
|
+
"{#{members.join(',')}}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse(body)
|
|
19
|
+
JSON.parse(body, create_additions: false)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def encode_wire_value(value)
|
|
23
|
+
case value
|
|
24
|
+
when String
|
|
25
|
+
JSON.generate(value)
|
|
26
|
+
when Integer
|
|
27
|
+
EcmaScriptNumberFormatter.format(value.to_f)
|
|
28
|
+
when Float
|
|
29
|
+
EcmaScriptNumberFormatter.format(value)
|
|
30
|
+
when true
|
|
31
|
+
"true"
|
|
32
|
+
when false
|
|
33
|
+
"false"
|
|
34
|
+
when Array
|
|
35
|
+
"[#{value.map { |item| JSON.generate(item) }.join(',')}]"
|
|
36
|
+
else
|
|
37
|
+
raise TypeError, "Unsupported JSON wire value #{value.class}."
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_class_method :encode_wire_value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ScreenshotScout
|
|
4
|
+
module Internal
|
|
5
|
+
WirePair = Data.define(:name, :value)
|
|
6
|
+
SerializedCaptureOptions = Data.define(:pairs, :body)
|
|
7
|
+
|
|
8
|
+
# Converts CaptureOptions into the API's exact ordered wire representation.
|
|
9
|
+
module Serializer
|
|
10
|
+
WIRE_NAMES = {
|
|
11
|
+
format: "format",
|
|
12
|
+
response_type: "response_type",
|
|
13
|
+
country: "country",
|
|
14
|
+
proxy: "proxy",
|
|
15
|
+
geolocation_latitude: "geolocation_latitude",
|
|
16
|
+
geolocation_longitude: "geolocation_longitude",
|
|
17
|
+
geolocation_accuracy: "geolocation_accuracy",
|
|
18
|
+
cookies: "cookies",
|
|
19
|
+
headers: "headers",
|
|
20
|
+
timeout: "timeout",
|
|
21
|
+
wait_until: "wait_until",
|
|
22
|
+
navigation_timeout: "navigation_timeout",
|
|
23
|
+
delay: "delay",
|
|
24
|
+
device: "device",
|
|
25
|
+
device_viewport_width: "device_viewport_width",
|
|
26
|
+
device_viewport_height: "device_viewport_height",
|
|
27
|
+
device_scale_factor: "device_scale_factor",
|
|
28
|
+
device_is_mobile: "device_is_mobile",
|
|
29
|
+
device_has_touch: "device_has_touch",
|
|
30
|
+
device_user_agent: "device_user_agent",
|
|
31
|
+
timezone: "timezone",
|
|
32
|
+
media_type: "media_type",
|
|
33
|
+
color_scheme: "color_scheme",
|
|
34
|
+
reduced_motion: "reduced_motion",
|
|
35
|
+
full_page: "full_page",
|
|
36
|
+
full_page_pre_scroll: "full_page_pre_scroll",
|
|
37
|
+
full_page_pre_scroll_step: "full_page_pre_scroll_step",
|
|
38
|
+
full_page_pre_scroll_step_delay: "full_page_pre_scroll_step_delay",
|
|
39
|
+
full_page_max_height: "full_page_max_height",
|
|
40
|
+
block_cookie_banners: "block_cookie_banners",
|
|
41
|
+
block_ads: "block_ads",
|
|
42
|
+
block_chat_widgets: "block_chat_widgets",
|
|
43
|
+
hide_selectors: "hide_selectors",
|
|
44
|
+
click_selectors: "click_selectors",
|
|
45
|
+
click_all_selectors: "click_all_selectors",
|
|
46
|
+
inject_css: "inject_css",
|
|
47
|
+
inject_js: "inject_js",
|
|
48
|
+
bypass_csp: "bypass_csp",
|
|
49
|
+
selector: "selector",
|
|
50
|
+
clip_x: "clip_x",
|
|
51
|
+
clip_y: "clip_y",
|
|
52
|
+
clip_width: "clip_width",
|
|
53
|
+
clip_height: "clip_height",
|
|
54
|
+
image_width: "image_width",
|
|
55
|
+
image_height: "image_height",
|
|
56
|
+
image_mode: "image_mode",
|
|
57
|
+
image_anchor: "image_anchor",
|
|
58
|
+
image_allow_upscale: "image_allow_upscale",
|
|
59
|
+
image_background: "image_background",
|
|
60
|
+
image_quality: "image_quality",
|
|
61
|
+
pdf_paper_format: "pdf_paper_format",
|
|
62
|
+
pdf_landscape: "pdf_landscape",
|
|
63
|
+
pdf_print_background: "pdf_print_background",
|
|
64
|
+
pdf_margin: "pdf_margin",
|
|
65
|
+
pdf_margin_top: "pdf_margin_top",
|
|
66
|
+
pdf_margin_right: "pdf_margin_right",
|
|
67
|
+
pdf_margin_bottom: "pdf_margin_bottom",
|
|
68
|
+
pdf_margin_left: "pdf_margin_left",
|
|
69
|
+
pdf_scale: "pdf_scale",
|
|
70
|
+
cache: "cache",
|
|
71
|
+
cache_ttl: "cache_ttl",
|
|
72
|
+
cache_key: "cache_key",
|
|
73
|
+
storage_mode: "storage_mode",
|
|
74
|
+
storage_endpoint: "storage_endpoint",
|
|
75
|
+
storage_bucket: "storage_bucket",
|
|
76
|
+
storage_region: "storage_region",
|
|
77
|
+
storage_object_key: "storage_object_key"
|
|
78
|
+
}.freeze
|
|
79
|
+
|
|
80
|
+
REPEATED_OPTIONS = %i[
|
|
81
|
+
cookies headers hide_selectors click_selectors click_all_selectors inject_css inject_js
|
|
82
|
+
].freeze
|
|
83
|
+
|
|
84
|
+
module_function
|
|
85
|
+
|
|
86
|
+
def serialize(target_url, options)
|
|
87
|
+
url = string_value(target_url, :url, target: true)
|
|
88
|
+
unless options.nil? || options.is_a?(CaptureOptions)
|
|
89
|
+
raise SerializationError, "Capture options must be a ScreenshotScout::CaptureOptions instance when provided."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
pairs = [WirePair.new(name: "url", value: url)]
|
|
93
|
+
body = { "url" => url }
|
|
94
|
+
WIRE_NAMES.each do |property, wire_name|
|
|
95
|
+
value = options&.public_send(property)
|
|
96
|
+
next if value.nil?
|
|
97
|
+
|
|
98
|
+
if REPEATED_OPTIONS.include?(property)
|
|
99
|
+
append_repeated(pairs, body, property, wire_name, value)
|
|
100
|
+
else
|
|
101
|
+
append_scalar(pairs, body, property, wire_name, value)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
SerializedCaptureOptions.new(pairs: pairs.freeze, body: body.freeze)
|
|
106
|
+
rescue Error
|
|
107
|
+
raise
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
raise SerializationError.new("Capture options could not be serialized."), cause: e
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_canonical_query(pairs, access_key)
|
|
113
|
+
indexed = (pairs + [WirePair.new(name: "access_key", value: access_key)]).each_with_index.map do |pair, index|
|
|
114
|
+
[pair, index]
|
|
115
|
+
end
|
|
116
|
+
indexed.sort! do |(left, left_index), (right, right_index)|
|
|
117
|
+
comparison = left.name <=> right.name
|
|
118
|
+
comparison.zero? ? left_index <=> right_index : comparison
|
|
119
|
+
end
|
|
120
|
+
encode_query(indexed.map(&:first))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def encode_query(pairs)
|
|
124
|
+
pairs.map { |pair| "#{form_encode(pair.name)}=#{form_encode(pair.value)}" }.join("&")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def append_repeated(pairs, body, property, wire_name, value)
|
|
128
|
+
unless value.is_a?(Array)
|
|
129
|
+
raise SerializationError.new(
|
|
130
|
+
"The capture option \"#{property}\" must be an array of strings.",
|
|
131
|
+
option: property
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
return if value.empty?
|
|
135
|
+
|
|
136
|
+
copied = value.map do |item|
|
|
137
|
+
string_value(item, property)
|
|
138
|
+
end
|
|
139
|
+
copied.each { |item| pairs << WirePair.new(name: wire_name, value: item) }
|
|
140
|
+
body[wire_name] = copied.freeze
|
|
141
|
+
end
|
|
142
|
+
private_class_method :append_repeated
|
|
143
|
+
|
|
144
|
+
def append_scalar(pairs, body, property, wire_name, value)
|
|
145
|
+
pair_value, body_value = scalar_value(value, property)
|
|
146
|
+
pairs << WirePair.new(name: wire_name, value: pair_value)
|
|
147
|
+
body[wire_name] = body_value
|
|
148
|
+
end
|
|
149
|
+
private_class_method :append_scalar
|
|
150
|
+
|
|
151
|
+
def scalar_value(value, property)
|
|
152
|
+
case value
|
|
153
|
+
when String
|
|
154
|
+
string = string_value(value, property)
|
|
155
|
+
[string, string]
|
|
156
|
+
when Integer
|
|
157
|
+
number = integer_value(value, property)
|
|
158
|
+
[number, value]
|
|
159
|
+
when Float
|
|
160
|
+
unless value.finite?
|
|
161
|
+
raise SerializationError.new(
|
|
162
|
+
"The capture option \"#{property}\" must be a finite number.",
|
|
163
|
+
option: property
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
[EcmaScriptNumberFormatter.format(value), value]
|
|
167
|
+
when true
|
|
168
|
+
["true", true]
|
|
169
|
+
when false
|
|
170
|
+
["false", false]
|
|
171
|
+
else
|
|
172
|
+
raise SerializationError.new(
|
|
173
|
+
"The capture option \"#{property}\" must be a string, finite number, or boolean.",
|
|
174
|
+
option: property
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
private_class_method :scalar_value
|
|
179
|
+
|
|
180
|
+
def integer_value(value, property)
|
|
181
|
+
float_value = value.to_f
|
|
182
|
+
return EcmaScriptNumberFormatter.format(float_value) if float_value.finite? && float_value.to_i == value
|
|
183
|
+
|
|
184
|
+
raise SerializationError.new(
|
|
185
|
+
"The capture option \"#{property}\" integer cannot be represented safely by the API.",
|
|
186
|
+
option: property
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
private_class_method :integer_value
|
|
190
|
+
|
|
191
|
+
def string_value(value, property, target: false)
|
|
192
|
+
unless value.is_a?(String)
|
|
193
|
+
message = if target
|
|
194
|
+
"The capture target URL must be a string."
|
|
195
|
+
else
|
|
196
|
+
"The capture option \"#{property}\" must be a string."
|
|
197
|
+
end
|
|
198
|
+
raise SerializationError.new(message, option: property)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
value.encode(Encoding::UTF_8)
|
|
202
|
+
rescue EncodingError => e
|
|
203
|
+
message = if target
|
|
204
|
+
"The capture target URL must contain valid UTF-8."
|
|
205
|
+
else
|
|
206
|
+
"The capture option \"#{property}\" must contain valid UTF-8."
|
|
207
|
+
end
|
|
208
|
+
raise SerializationError.new(message, option: property), cause: e
|
|
209
|
+
end
|
|
210
|
+
private_class_method :string_value
|
|
211
|
+
|
|
212
|
+
def form_encode(value)
|
|
213
|
+
value.bytes.map do |byte|
|
|
214
|
+
if byte.between?(65, 90) || byte.between?(97, 122) ||
|
|
215
|
+
byte.between?(48, 57) || [42, 45, 46, 95].include?(byte)
|
|
216
|
+
byte.chr
|
|
217
|
+
elsif byte == 32
|
|
218
|
+
"+"
|
|
219
|
+
else
|
|
220
|
+
format("%%%02X", byte)
|
|
221
|
+
end
|
|
222
|
+
end.join
|
|
223
|
+
end
|
|
224
|
+
private_class_method :form_encode
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ScreenshotScout
|
|
4
|
+
# Exact buffered HTTP response data retained on successes and API failures.
|
|
5
|
+
class RawResponse
|
|
6
|
+
attr_reader :status, :reason_phrase, :headers, :content_type, :body
|
|
7
|
+
|
|
8
|
+
def initialize(status:, reason_phrase:, headers:, content_type:, body:)
|
|
9
|
+
@status = status
|
|
10
|
+
@reason_phrase = reason_phrase.dup.freeze
|
|
11
|
+
@headers = copy_headers(headers)
|
|
12
|
+
@normalized_headers = @headers.to_h do |name, values|
|
|
13
|
+
[name.downcase, values]
|
|
14
|
+
end.freeze
|
|
15
|
+
@content_type = content_type&.dup&.freeze
|
|
16
|
+
@body = body.dup.freeze
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def header(name)
|
|
21
|
+
@normalized_headers.fetch(name.downcase, EMPTY_HEADER_VALUES)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
EMPTY_HEADER_VALUES = [].freeze
|
|
25
|
+
private_constant :EMPTY_HEADER_VALUES
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def copy_headers(headers)
|
|
30
|
+
headers.to_h do |name, values|
|
|
31
|
+
copied_values = Array(values).map { |value| value.to_s.dup.freeze }.freeze
|
|
32
|
+
[name.to_s.dup.freeze, copied_values]
|
|
33
|
+
end.freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Shared response base that exposes the exact buffered HTTP response.
|
|
38
|
+
class CaptureResponse
|
|
39
|
+
attr_reader :raw_response
|
|
40
|
+
|
|
41
|
+
def initialize(raw_response:)
|
|
42
|
+
@raw_response = raw_response
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Successful binary capture, including response metadata and image/PDF bytes.
|
|
47
|
+
class BinaryCaptureResponse < CaptureResponse
|
|
48
|
+
attr_reader :bytes, :screenshot_url, :screenshot_url_expires_at, :cache_status
|
|
49
|
+
|
|
50
|
+
def initialize(raw_response:, screenshot_url:, screenshot_url_expires_at:, cache_status:)
|
|
51
|
+
super(raw_response: raw_response)
|
|
52
|
+
@bytes = raw_response.body
|
|
53
|
+
@screenshot_url = screenshot_url
|
|
54
|
+
@screenshot_url_expires_at = screenshot_url_expires_at
|
|
55
|
+
@cache_status = cache_status
|
|
56
|
+
freeze
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Structured capture result returned when response_type is json.
|
|
61
|
+
class CaptureResult
|
|
62
|
+
attr_reader :screenshot_url, :screenshot_url_expires_at, :cache_status, :additional_fields
|
|
63
|
+
|
|
64
|
+
def initialize(screenshot_url:, screenshot_url_expires_at:, cache_status:, additional_fields:)
|
|
65
|
+
@screenshot_url = screenshot_url
|
|
66
|
+
@screenshot_url_expires_at = screenshot_url_expires_at
|
|
67
|
+
@cache_status = cache_status
|
|
68
|
+
@additional_fields = additional_fields.freeze
|
|
69
|
+
freeze
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Successful JSON capture response and its structured result.
|
|
74
|
+
class JsonCaptureResponse < CaptureResponse
|
|
75
|
+
attr_reader :result
|
|
76
|
+
|
|
77
|
+
def initialize(result:, raw_response:)
|
|
78
|
+
super(raw_response: raw_response)
|
|
79
|
+
@result = result
|
|
80
|
+
freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|