screentake 0.1.0
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.txt +21 -0
- data/README.md +519 -0
- data/Rakefile +12 -0
- data/lib/generators/screentake/install/install_generator.rb +15 -0
- data/lib/generators/screentake/install/templates/screentake.rb +25 -0
- data/lib/screentake/configuration.rb +27 -0
- data/lib/screentake/drivers/base.rb +19 -0
- data/lib/screentake/drivers/cloudflare.rb +188 -0
- data/lib/screentake/drivers/ferrum.rb +176 -0
- data/lib/screentake/errors.rb +54 -0
- data/lib/screentake/railtie.rb +18 -0
- data/lib/screentake/screenshot.rb +250 -0
- data/lib/screentake/version.rb +5 -0
- data/lib/screentake.rb +80 -0
- metadata +74 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Screentake
|
|
8
|
+
module Drivers
|
|
9
|
+
class Cloudflare < Base
|
|
10
|
+
MAX_RETRIES = 3
|
|
11
|
+
INITIAL_BACKOFF = 1.0 # seconds
|
|
12
|
+
|
|
13
|
+
def initialize(account_id: nil, api_token: nil)
|
|
14
|
+
super()
|
|
15
|
+
config = Screentake.configuration
|
|
16
|
+
@account_id = account_id || config.cloudflare_account_id
|
|
17
|
+
@api_token = api_token || config.cloudflare_api_token
|
|
18
|
+
validate_credentials!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def screenshot(source:, type:, options:)
|
|
22
|
+
body = build_request_body(source, type, options)
|
|
23
|
+
response = perform_request(body)
|
|
24
|
+
handle_response(response)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def validate_credentials!
|
|
30
|
+
if @account_id.nil? || @account_id.to_s.strip.empty?
|
|
31
|
+
raise AuthenticationError,
|
|
32
|
+
"Cloudflare account ID is required. Set it via Screentake.configure or CLOUDFLARE_ACCOUNT_ID env var."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return unless @api_token.nil? || @api_token.to_s.strip.empty?
|
|
36
|
+
|
|
37
|
+
raise AuthenticationError,
|
|
38
|
+
"Cloudflare API token is required. Set it via Screentake.configure or CLOUDFLARE_API_TOKEN env var."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def endpoint_uri
|
|
42
|
+
URI("https://api.cloudflare.com/client/v4/accounts/#{@account_id}/browser-rendering/screenshot")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_request_body(source, type, options)
|
|
46
|
+
body = {}
|
|
47
|
+
|
|
48
|
+
# Source
|
|
49
|
+
if type == :url
|
|
50
|
+
body["url"] = source
|
|
51
|
+
else
|
|
52
|
+
body["html"] = source
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Viewport
|
|
56
|
+
body["viewport"] = {
|
|
57
|
+
"width" => options[:width],
|
|
58
|
+
"height" => options[:height],
|
|
59
|
+
"deviceScaleFactor" => options[:scale],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Screenshot options
|
|
63
|
+
screenshot_opts = {}
|
|
64
|
+
screenshot_opts["fullPage"] = options[:full_page] if options[:full_page]
|
|
65
|
+
screenshot_opts["omitBackground"] = true if options[:transparent]
|
|
66
|
+
|
|
67
|
+
format = options[:format]
|
|
68
|
+
screenshot_opts["type"] = format == :jpeg ? "jpeg" : format.to_s
|
|
69
|
+
|
|
70
|
+
screenshot_opts["quality"] = options[:quality] if options[:quality] && format != :png
|
|
71
|
+
|
|
72
|
+
if options[:clip]
|
|
73
|
+
screenshot_opts["clip"] = {
|
|
74
|
+
"x" => options[:clip][:x],
|
|
75
|
+
"y" => options[:clip][:y],
|
|
76
|
+
"width" => options[:clip][:width],
|
|
77
|
+
"height" => options[:clip][:height],
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
body["screenshotOptions"] = screenshot_opts unless screenshot_opts.empty?
|
|
82
|
+
|
|
83
|
+
# Selector
|
|
84
|
+
body["selector"] = options[:selector] if options[:selector]
|
|
85
|
+
|
|
86
|
+
# Navigation / wait options
|
|
87
|
+
goto_options = {}
|
|
88
|
+
goto_options["waitUntil"] = options[:wait_until].to_s if options[:wait_until]
|
|
89
|
+
goto_options["timeout"] = Screentake.configuration.timeout
|
|
90
|
+
body["gotoOptions"] = goto_options unless goto_options.empty?
|
|
91
|
+
|
|
92
|
+
# Wait for selector
|
|
93
|
+
if options[:wait_for_selector]
|
|
94
|
+
body["waitForSelector"] = { "selector" => options[:wait_for_selector] }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Wait for timeout (additional delay)
|
|
98
|
+
body["waitForTimeout"] = options[:wait_for_timeout] if options[:wait_for_timeout]
|
|
99
|
+
|
|
100
|
+
body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def perform_request(body)
|
|
104
|
+
uri = endpoint_uri
|
|
105
|
+
retries = 0
|
|
106
|
+
|
|
107
|
+
loop do
|
|
108
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
109
|
+
http.use_ssl = true
|
|
110
|
+
http.open_timeout = 10
|
|
111
|
+
http.read_timeout = (Screentake.configuration.timeout / 1000.0).ceil + 5
|
|
112
|
+
|
|
113
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
114
|
+
request["Authorization"] = "Bearer #{@api_token}"
|
|
115
|
+
request["Content-Type"] = "application/json"
|
|
116
|
+
request.body = JSON.generate(body)
|
|
117
|
+
|
|
118
|
+
log_request(body)
|
|
119
|
+
|
|
120
|
+
response = http.request(request)
|
|
121
|
+
|
|
122
|
+
if response.code == "429" && retries < MAX_RETRIES
|
|
123
|
+
retries += 1
|
|
124
|
+
backoff = INITIAL_BACKOFF * (2**(retries - 1))
|
|
125
|
+
log_retry(retries, backoff)
|
|
126
|
+
sleep(backoff)
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
return response
|
|
131
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
132
|
+
raise Screentake::TimeoutError, "Request to Cloudflare timed out: #{e.message}"
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
raise Screentake::DriverError, "Cloudflare request failed: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def handle_response(response)
|
|
139
|
+
case response.code
|
|
140
|
+
when "200"
|
|
141
|
+
response.body
|
|
142
|
+
when "400"
|
|
143
|
+
error_message = parse_error(response)
|
|
144
|
+
if error_message&.include?("selector")
|
|
145
|
+
raise Screentake::ElementNotFoundError, "Element not found: #{error_message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raise Screentake::RenderError, "Cloudflare rendering failed: #{error_message}"
|
|
149
|
+
when "401", "403"
|
|
150
|
+
raise Screentake::AuthenticationError,
|
|
151
|
+
"Cloudflare authentication failed. Check your API token and account ID."
|
|
152
|
+
when "429"
|
|
153
|
+
raise Screentake::RateLimitError,
|
|
154
|
+
"Cloudflare rate limit exceeded. Max 2 browsers/minute, 2 concurrent."
|
|
155
|
+
else
|
|
156
|
+
error_message = parse_error(response)
|
|
157
|
+
raise Screentake::CloudflareError,
|
|
158
|
+
"Cloudflare API error (HTTP #{response.code}): #{error_message}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def parse_error(response)
|
|
163
|
+
data = JSON.parse(response.body)
|
|
164
|
+
if data.is_a?(Hash) && data["errors"]
|
|
165
|
+
data["errors"].map { |e| e["message"] }.join(", ")
|
|
166
|
+
else
|
|
167
|
+
response.body
|
|
168
|
+
end
|
|
169
|
+
rescue JSON::ParserError
|
|
170
|
+
response.body
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def log_request(body)
|
|
174
|
+
logger = Screentake.configuration.logger
|
|
175
|
+
return unless logger
|
|
176
|
+
|
|
177
|
+
logger.debug("[Screentake] Cloudflare screenshot request: #{body.reject { |k, _| k == "html" }.inspect}")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def log_retry(attempt, backoff)
|
|
181
|
+
logger = Screentake.configuration.logger
|
|
182
|
+
return unless logger
|
|
183
|
+
|
|
184
|
+
logger.warn("[Screentake] Rate limited, retrying (#{attempt}/#{MAX_RETRIES}) after #{backoff}s")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Screentake
|
|
4
|
+
module Drivers
|
|
5
|
+
class Ferrum < Base
|
|
6
|
+
FERRUM_REQUIRE_ERROR = "Ferrum driver requires the 'ferrum' gem. Add gem 'ferrum' to your Gemfile."
|
|
7
|
+
CHROME_NOT_FOUND_MSG = "Chrome/Chromium not found. Install Chrome or set config.ferrum_options[:browser_path]."
|
|
8
|
+
WAIT_POLL_INTERVAL = 0.05 # seconds
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
super()
|
|
12
|
+
require_ferrum!
|
|
13
|
+
at_exit { quit }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def screenshot(source:, type:, options:)
|
|
17
|
+
context = browser.contexts.create
|
|
18
|
+
page = context.create_page
|
|
19
|
+
begin
|
|
20
|
+
configure_viewport(page, options)
|
|
21
|
+
navigate(page, source, type)
|
|
22
|
+
apply_wait_strategies(page, options)
|
|
23
|
+
take_screenshot(page, options)
|
|
24
|
+
rescue ::Ferrum::TimeoutError => e
|
|
25
|
+
raise Screentake::TimeoutError, e.message
|
|
26
|
+
rescue ::Ferrum::NodeNotFoundError => e
|
|
27
|
+
raise Screentake::ElementNotFoundError, e.message
|
|
28
|
+
rescue ::Ferrum::StatusError => e
|
|
29
|
+
raise Screentake::RenderError, e.message
|
|
30
|
+
rescue ::Ferrum::DeadBrowserError => e
|
|
31
|
+
reset_browser!
|
|
32
|
+
raise Screentake::BrowserCrashedError, e.message
|
|
33
|
+
rescue ::Ferrum::ProcessTimeoutError => e
|
|
34
|
+
reset_browser!
|
|
35
|
+
raise Screentake::BrowserCrashedError, e.message
|
|
36
|
+
rescue ::Ferrum::Error => e
|
|
37
|
+
raise Screentake::FerrumError, e.message
|
|
38
|
+
ensure
|
|
39
|
+
context&.dispose
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Shut down the browser process. Called by reset_configuration! and at_exit.
|
|
44
|
+
def quit
|
|
45
|
+
return unless @browser
|
|
46
|
+
|
|
47
|
+
log(:info, "Quitting browser")
|
|
48
|
+
@browser.quit
|
|
49
|
+
@browser = nil
|
|
50
|
+
rescue StandardError
|
|
51
|
+
@browser = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def require_ferrum!
|
|
57
|
+
require "ferrum"
|
|
58
|
+
rescue LoadError
|
|
59
|
+
raise Screentake::DriverError, FERRUM_REQUIRE_ERROR
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def browser
|
|
63
|
+
@browser ||= begin
|
|
64
|
+
opts = Screentake.configuration.ferrum_options.dup
|
|
65
|
+
log(:info, "Launching browser")
|
|
66
|
+
::Ferrum::Browser.new(**opts)
|
|
67
|
+
rescue ::Ferrum::ProcessTimeoutError
|
|
68
|
+
raise Screentake::BrowserNotFoundError, CHROME_NOT_FOUND_MSG
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reset_browser!
|
|
73
|
+
@browser&.quit rescue nil # rubocop:disable Style/RescueModifier
|
|
74
|
+
@browser = nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def configure_viewport(page, options)
|
|
78
|
+
page.set_viewport(
|
|
79
|
+
width: options[:width],
|
|
80
|
+
height: options[:height],
|
|
81
|
+
scale_factor: options[:scale],
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def navigate(page, source, type)
|
|
86
|
+
if type == :url
|
|
87
|
+
log(:debug, "Navigating to #{source}")
|
|
88
|
+
page.go_to(source)
|
|
89
|
+
else
|
|
90
|
+
log(:debug, "Loading HTML content (#{source.length} bytes)")
|
|
91
|
+
page.content = source
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def apply_wait_strategies(page, options)
|
|
96
|
+
apply_network_wait(page, options[:wait_until])
|
|
97
|
+
wait_for_selector(page, options[:wait_for_selector]) if options[:wait_for_selector]
|
|
98
|
+
sleep(options[:wait_for_timeout] / 1000.0) if options[:wait_for_timeout]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def apply_network_wait(page, wait_until)
|
|
102
|
+
case wait_until
|
|
103
|
+
when :networkidle0
|
|
104
|
+
page.network.wait_for_idle(connections: 0)
|
|
105
|
+
when :networkidle2
|
|
106
|
+
page.network.wait_for_idle(connections: 2)
|
|
107
|
+
when :domcontentloaded, :load
|
|
108
|
+
# Navigation already waited for load; no extra wait needed
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def wait_for_selector(page, selector)
|
|
114
|
+
timeout_sec = Screentake.configuration.timeout / 1000.0
|
|
115
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_sec
|
|
116
|
+
|
|
117
|
+
loop do
|
|
118
|
+
return if page.at_css(selector)
|
|
119
|
+
|
|
120
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
121
|
+
raise Screentake::TimeoutError, "Timed out waiting for selector: #{selector}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
sleep(WAIT_POLL_INTERVAL)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def take_screenshot(page, options)
|
|
129
|
+
screenshot_opts = build_screenshot_opts(options)
|
|
130
|
+
log(:debug, "Screenshot options: #{screenshot_opts.inspect}")
|
|
131
|
+
page.screenshot(**screenshot_opts)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_screenshot_opts(options)
|
|
135
|
+
opts = { encoding: :binary }
|
|
136
|
+
|
|
137
|
+
# Format
|
|
138
|
+
format = options[:format]
|
|
139
|
+
opts[:format] = format.to_s
|
|
140
|
+
|
|
141
|
+
# Quality (jpeg/webp only)
|
|
142
|
+
opts[:quality] = options[:quality] if options[:quality] && format != :png
|
|
143
|
+
|
|
144
|
+
# Full page
|
|
145
|
+
opts[:full] = true if options[:full_page]
|
|
146
|
+
|
|
147
|
+
# Selector
|
|
148
|
+
opts[:selector] = options[:selector] if options[:selector]
|
|
149
|
+
|
|
150
|
+
# Clip region
|
|
151
|
+
if options[:clip]
|
|
152
|
+
opts[:area] = {
|
|
153
|
+
x: options[:clip][:x].to_i,
|
|
154
|
+
y: options[:clip][:y].to_i,
|
|
155
|
+
width: options[:clip][:width].to_i,
|
|
156
|
+
height: options[:clip][:height].to_i,
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Transparent background
|
|
161
|
+
if options[:transparent]
|
|
162
|
+
opts[:background_color] = ::Ferrum::RGBA.new(0, 0, 0, 0.0)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
opts
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def log(level, message)
|
|
169
|
+
logger = Screentake.configuration.logger
|
|
170
|
+
return unless logger
|
|
171
|
+
|
|
172
|
+
logger.public_send(level, "[Screentake] Ferrum: #{message}")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Screentake
|
|
4
|
+
# Base error class for all Screentake errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a required method is not implemented by a subclass
|
|
8
|
+
class NotImplementedError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a URL is malformed or invalid
|
|
11
|
+
class InvalidUrlError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when HTML content is invalid
|
|
14
|
+
class InvalidHtmlError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when viewport dimensions are out of range
|
|
17
|
+
class InvalidDimensionError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when an option value is invalid
|
|
20
|
+
class InvalidOptionError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a clip region is invalid
|
|
23
|
+
class InvalidClipError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when a CSS selector matches no elements
|
|
26
|
+
class ElementNotFoundError < Error; end
|
|
27
|
+
|
|
28
|
+
# Raised when an operation exceeds its timeout
|
|
29
|
+
class TimeoutError < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when the rendering engine fails
|
|
32
|
+
class RenderError < Error; end
|
|
33
|
+
|
|
34
|
+
# Base error for driver-specific failures
|
|
35
|
+
class DriverError < Error; end
|
|
36
|
+
|
|
37
|
+
# Base error for Cloudflare driver failures
|
|
38
|
+
class CloudflareError < DriverError; end
|
|
39
|
+
|
|
40
|
+
# Raised when Cloudflare returns a 429 rate limit response
|
|
41
|
+
class RateLimitError < CloudflareError; end
|
|
42
|
+
|
|
43
|
+
# Raised when Cloudflare API authentication fails
|
|
44
|
+
class AuthenticationError < CloudflareError; end
|
|
45
|
+
|
|
46
|
+
# Base error for Ferrum driver failures
|
|
47
|
+
class FerrumError < DriverError; end
|
|
48
|
+
|
|
49
|
+
# Raised when Chrome/Chromium binary not found
|
|
50
|
+
class BrowserNotFoundError < FerrumError; end
|
|
51
|
+
|
|
52
|
+
# Raised when browser process crashes or becomes unresponsive
|
|
53
|
+
class BrowserCrashedError < FerrumError; end
|
|
54
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Screentake
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
initializer "screentake.configure" do
|
|
6
|
+
config = Screentake.configuration
|
|
7
|
+
config.logger ||= Rails.logger
|
|
8
|
+
|
|
9
|
+
if config.cloudflare_account_id.nil?
|
|
10
|
+
config.cloudflare_account_id = ENV.fetch("CLOUDFLARE_ACCOUNT_ID", nil)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
if config.cloudflare_api_token.nil?
|
|
14
|
+
config.cloudflare_api_token = ENV.fetch("CLOUDFLARE_API_TOKEN", nil)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Screentake
|
|
8
|
+
class Screenshot
|
|
9
|
+
VALID_FORMATS = [:png, :jpeg, :webp].freeze
|
|
10
|
+
VALID_WAIT_UNTIL = [:load, :domcontentloaded, :networkidle0, :networkidle2].freeze
|
|
11
|
+
MIN_DIMENSION = 1
|
|
12
|
+
MAX_DIMENSION = 10_000
|
|
13
|
+
MIN_SCALE = 1
|
|
14
|
+
MAX_SCALE = 4
|
|
15
|
+
MIN_QUALITY = 1
|
|
16
|
+
MAX_QUALITY = 100
|
|
17
|
+
MAX_WAIT_TIMEOUT = 30_000
|
|
18
|
+
|
|
19
|
+
attr_reader :options
|
|
20
|
+
|
|
21
|
+
def initialize(**kwargs)
|
|
22
|
+
@options = build_options(**kwargs)
|
|
23
|
+
validate!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Save screenshot to a file path. Returns the path.
|
|
27
|
+
def save(path)
|
|
28
|
+
path = Pathname(path)
|
|
29
|
+
resolved = resolve_format_from_path(path)
|
|
30
|
+
data = perform_capture(resolved)
|
|
31
|
+
path.dirname.mkpath
|
|
32
|
+
path.binwrite(data)
|
|
33
|
+
path.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Return raw binary screenshot data.
|
|
37
|
+
def capture
|
|
38
|
+
perform_capture(@options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Return base64-encoded screenshot data.
|
|
42
|
+
def base64
|
|
43
|
+
Base64.strict_encode64(capture)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Return a hash of all resolved options for debugging.
|
|
47
|
+
def debug
|
|
48
|
+
{
|
|
49
|
+
source_type: @options[:source_type],
|
|
50
|
+
source: @options[:source_type] == :url ? @options[:url] : "(html)",
|
|
51
|
+
width: @options[:width],
|
|
52
|
+
height: @options[:height],
|
|
53
|
+
scale: @options[:scale],
|
|
54
|
+
format: @options[:format],
|
|
55
|
+
quality: @options[:quality],
|
|
56
|
+
full_page: @options[:full_page],
|
|
57
|
+
selector: @options[:selector],
|
|
58
|
+
clip: @options[:clip],
|
|
59
|
+
transparent: @options[:transparent],
|
|
60
|
+
wait_until: @options[:wait_until],
|
|
61
|
+
wait_for_selector: @options[:wait_for_selector],
|
|
62
|
+
wait_for_timeout: @options[:wait_for_timeout],
|
|
63
|
+
driver: Screentake.configuration.driver,
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def build_options(**kwargs)
|
|
70
|
+
config = Screentake.configuration
|
|
71
|
+
|
|
72
|
+
source_type, source_value = resolve_source(kwargs[:url], kwargs[:html])
|
|
73
|
+
width, height = resolve_dimensions(kwargs, config)
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
source_type: source_type,
|
|
77
|
+
url: source_type == :url ? source_value : nil,
|
|
78
|
+
html: source_type == :html ? source_value : nil,
|
|
79
|
+
width: width,
|
|
80
|
+
height: height,
|
|
81
|
+
scale: kwargs.fetch(:scale, config.default_scale),
|
|
82
|
+
format: kwargs.fetch(:format, config.default_format),
|
|
83
|
+
quality: kwargs[:quality],
|
|
84
|
+
full_page: kwargs.fetch(:full_page, false),
|
|
85
|
+
selector: kwargs[:selector],
|
|
86
|
+
clip: kwargs[:clip],
|
|
87
|
+
transparent: kwargs.fetch(:transparent, false),
|
|
88
|
+
wait_until: kwargs.fetch(:wait_until, :networkidle0),
|
|
89
|
+
wait_for_selector: kwargs[:wait_for_selector],
|
|
90
|
+
wait_for_timeout: kwargs[:wait_for_timeout],
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_source(url, html)
|
|
95
|
+
if url && html
|
|
96
|
+
raise ArgumentError, "Cannot specify both url: and html: options"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if url
|
|
100
|
+
[:url, url]
|
|
101
|
+
elsif html
|
|
102
|
+
[:html, html]
|
|
103
|
+
else
|
|
104
|
+
raise ArgumentError, "Must specify either url: or html: option"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_dimensions(kwargs, config)
|
|
109
|
+
if kwargs[:size]
|
|
110
|
+
size = kwargs[:size]
|
|
111
|
+
unless size.is_a?(Array) && size.length == 2
|
|
112
|
+
raise InvalidDimensionError, "size: must be a [width, height] array"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
[size[0], size[1]]
|
|
116
|
+
else
|
|
117
|
+
[
|
|
118
|
+
kwargs.fetch(:width, config.default_viewport[:width]),
|
|
119
|
+
kwargs.fetch(:height, config.default_viewport[:height]),
|
|
120
|
+
]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate!
|
|
125
|
+
validate_source!
|
|
126
|
+
validate_dimensions!
|
|
127
|
+
validate_scale!
|
|
128
|
+
validate_format!
|
|
129
|
+
validate_quality!
|
|
130
|
+
validate_clip!
|
|
131
|
+
validate_wait!
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_source!
|
|
135
|
+
if @options[:source_type] == :url
|
|
136
|
+
validate_url!(@options[:url])
|
|
137
|
+
else
|
|
138
|
+
validate_html!(@options[:html])
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_url!(url)
|
|
143
|
+
uri = URI.parse(url)
|
|
144
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
145
|
+
raise InvalidUrlError, "URL must use http or https scheme: #{url}"
|
|
146
|
+
end
|
|
147
|
+
rescue URI::InvalidURIError
|
|
148
|
+
raise InvalidUrlError, "Malformed URL: #{url}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_html!(html)
|
|
152
|
+
unless html.is_a?(String) && !html.strip.empty?
|
|
153
|
+
raise InvalidHtmlError, "HTML must be a non-empty string"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_dimensions!
|
|
158
|
+
[:width, :height].each do |dim|
|
|
159
|
+
value = @options[dim]
|
|
160
|
+
unless value.is_a?(Integer) && value >= MIN_DIMENSION && value <= MAX_DIMENSION
|
|
161
|
+
raise InvalidDimensionError,
|
|
162
|
+
"#{dim}: must be an integer between #{MIN_DIMENSION} and #{MAX_DIMENSION}, got #{value.inspect}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_scale!
|
|
168
|
+
scale = @options[:scale]
|
|
169
|
+
unless scale.is_a?(Numeric) && scale >= MIN_SCALE && scale <= MAX_SCALE
|
|
170
|
+
raise InvalidOptionError,
|
|
171
|
+
"scale: must be between #{MIN_SCALE} and #{MAX_SCALE}, got #{scale.inspect}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validate_format!
|
|
176
|
+
format = @options[:format]
|
|
177
|
+
unless VALID_FORMATS.include?(format)
|
|
178
|
+
raise InvalidOptionError,
|
|
179
|
+
"format: must be one of #{VALID_FORMATS.inspect}, got #{format.inspect}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_quality!
|
|
184
|
+
quality = @options[:quality]
|
|
185
|
+
return if quality.nil?
|
|
186
|
+
|
|
187
|
+
if @options[:format] == :png
|
|
188
|
+
raise InvalidOptionError, "quality: is not supported for PNG format"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
unless quality.is_a?(Integer) && quality >= MIN_QUALITY && quality <= MAX_QUALITY
|
|
192
|
+
raise InvalidOptionError,
|
|
193
|
+
"quality: must be an integer between #{MIN_QUALITY} and #{MAX_QUALITY}, got #{quality.inspect}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def validate_clip!
|
|
198
|
+
clip = @options[:clip]
|
|
199
|
+
return if clip.nil?
|
|
200
|
+
|
|
201
|
+
required_keys = [:x, :y, :width, :height]
|
|
202
|
+
missing = required_keys - clip.keys
|
|
203
|
+
unless missing.empty?
|
|
204
|
+
raise InvalidClipError, "clip: missing required keys: #{missing.inspect}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
clip.each do |key, value|
|
|
208
|
+
unless value.is_a?(Numeric) && value >= 0
|
|
209
|
+
raise InvalidClipError, "clip[:#{key}] must be a non-negative number, got #{value.inspect}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_wait!
|
|
215
|
+
wait_until = @options[:wait_until]
|
|
216
|
+
unless VALID_WAIT_UNTIL.include?(wait_until)
|
|
217
|
+
raise InvalidOptionError,
|
|
218
|
+
"wait_until: must be one of #{VALID_WAIT_UNTIL.inspect}, got #{wait_until.inspect}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
timeout = @options[:wait_for_timeout]
|
|
222
|
+
return if timeout.nil?
|
|
223
|
+
|
|
224
|
+
unless timeout.is_a?(Numeric) && timeout > 0 && timeout <= MAX_WAIT_TIMEOUT
|
|
225
|
+
raise InvalidOptionError,
|
|
226
|
+
"wait_for_timeout: must be between 1 and #{MAX_WAIT_TIMEOUT}, got #{timeout.inspect}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def resolve_format_from_path(path)
|
|
231
|
+
ext = path.extname.downcase.delete(".")
|
|
232
|
+
format = case ext
|
|
233
|
+
when "jpg", "jpeg" then :jpeg
|
|
234
|
+
when "webp" then :webp
|
|
235
|
+
when "png" then :png
|
|
236
|
+
else @options[:format]
|
|
237
|
+
end
|
|
238
|
+
@options.merge(format: format)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def perform_capture(opts)
|
|
242
|
+
driver = Screentake.driver
|
|
243
|
+
driver.screenshot(
|
|
244
|
+
source: opts[:source_type] == :url ? opts[:url] : opts[:html],
|
|
245
|
+
type: opts[:source_type],
|
|
246
|
+
options: opts,
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|