bidi2pdf 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/.idea/.gitignore +8 -0
- data/.rspec +3 -0
- data/.rubocop.yml +50 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +119 -0
- data/Rakefile +22 -0
- data/docker/Dockerfile +35 -0
- data/docker/docker-compose.yml +1 -0
- data/exe/bidi2pdf +7 -0
- data/lib/bidi2pdf/bidi/add_headers_interceptor.rb +42 -0
- data/lib/bidi2pdf/bidi/auth_interceptor.rb +67 -0
- data/lib/bidi2pdf/bidi/browser.rb +15 -0
- data/lib/bidi2pdf/bidi/browser_tab.rb +180 -0
- data/lib/bidi2pdf/bidi/client.rb +224 -0
- data/lib/bidi2pdf/bidi/event_manager.rb +84 -0
- data/lib/bidi2pdf/bidi/network_event.rb +54 -0
- data/lib/bidi2pdf/bidi/network_events.rb +82 -0
- data/lib/bidi2pdf/bidi/print_parameters_validator.rb +114 -0
- data/lib/bidi2pdf/bidi/session.rb +135 -0
- data/lib/bidi2pdf/bidi/user_context.rb +75 -0
- data/lib/bidi2pdf/bidi/web_socket_dispatcher.rb +70 -0
- data/lib/bidi2pdf/chromedriver_manager.rb +160 -0
- data/lib/bidi2pdf/cli.rb +118 -0
- data/lib/bidi2pdf/launcher.rb +46 -0
- data/lib/bidi2pdf/session_runner.rb +123 -0
- data/lib/bidi2pdf/utils.rb +15 -0
- data/lib/bidi2pdf/version.rb +5 -0
- data/lib/bidi2pdf.rb +25 -0
- data/sig/bidi2pdf/chrome/chromedriver_downloader.rbs +11 -0
- data/sig/bidi2pdf/chrome/downloader_helper.rbs +9 -0
- data/sig/bidi2pdf/chrome/finder.rbs +27 -0
- data/sig/bidi2pdf/chrome/platform.rbs +13 -0
- data/sig/bidi2pdf/chrome/version_resolver.rbs +19 -0
- data/sig/bidi2pdf.rbs +4 -0
- data/tmp/.keep +0 -0
- metadata +327 -0
@@ -0,0 +1,224 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "websocket-client-simple"
|
5
|
+
|
6
|
+
require_relative "web_socket_dispatcher"
|
7
|
+
require_relative "add_headers_interceptor"
|
8
|
+
require_relative "auth_interceptor"
|
9
|
+
|
10
|
+
module Bidi2pdf
|
11
|
+
module Bidi
|
12
|
+
class Client
|
13
|
+
include Bidi2pdf::Utils
|
14
|
+
|
15
|
+
attr_reader :ws_url
|
16
|
+
|
17
|
+
def initialize(ws_url)
|
18
|
+
@ws_url = ws_url
|
19
|
+
@id = 0
|
20
|
+
@pending_responses = {}
|
21
|
+
|
22
|
+
@connected = false
|
23
|
+
@connection_mutex = Mutex.new
|
24
|
+
@send_cmd_mutex = Mutex.new
|
25
|
+
@connection_cv = ConditionVariable.new
|
26
|
+
|
27
|
+
@started = false
|
28
|
+
end
|
29
|
+
|
30
|
+
def start
|
31
|
+
return @socket if started?
|
32
|
+
|
33
|
+
@socket = WebSocket::Client::Simple.connect(ws_url)
|
34
|
+
@dispatcher = WebSocketDispatcher.new(@socket)
|
35
|
+
|
36
|
+
@dispatcher.on_open { handle_open }
|
37
|
+
@dispatcher.on_message { |data| handle_response_to_cmd(data) }
|
38
|
+
|
39
|
+
@dispatcher.start_listening
|
40
|
+
|
41
|
+
@started = true
|
42
|
+
|
43
|
+
@socket
|
44
|
+
end
|
45
|
+
|
46
|
+
def started?
|
47
|
+
@started
|
48
|
+
end
|
49
|
+
|
50
|
+
def wait_until_open(timeout: Bidi2pdf.default_timeout)
|
51
|
+
@connection_mutex.synchronize do
|
52
|
+
unless @connected
|
53
|
+
Bidi2pdf.logger.debug "Waiting for WebSocket connection to open"
|
54
|
+
@connection_cv.wait(@connection_mutex, timeout)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
raise "WebSocket connection did not open in time" unless @connected
|
59
|
+
|
60
|
+
Bidi2pdf.logger.debug "WebSocket connection is open"
|
61
|
+
end
|
62
|
+
|
63
|
+
def send_cmd(method, params = {})
|
64
|
+
next_id.tap do |cmd_id|
|
65
|
+
payload = {
|
66
|
+
id: cmd_id,
|
67
|
+
method: method,
|
68
|
+
params: params
|
69
|
+
}
|
70
|
+
|
71
|
+
Bidi2pdf.logger.debug "Sending command: #{redact_sensitive_fields(payload).inspect}"
|
72
|
+
|
73
|
+
@socket.send(payload.to_json)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# rubocop:disable Metrics/AbcSize
|
78
|
+
def send_cmd_and_wait(method, params = {}, timeout: Bidi2pdf.default_timeout)
|
79
|
+
timed("Command #{method}") do
|
80
|
+
id = send_cmd(method, params)
|
81
|
+
queue = @pending_responses[id]
|
82
|
+
|
83
|
+
begin
|
84
|
+
response = queue.pop(true)
|
85
|
+
rescue ThreadError
|
86
|
+
response = queue.pop(timeout: timeout)
|
87
|
+
end
|
88
|
+
|
89
|
+
if response.nil?
|
90
|
+
# rubocop:disable Layout/LineLength
|
91
|
+
Bidi2pdf.logger.error "Timeout waiting for response to command #{id}, cmd: #{method}, params: #{redact_sensitive_fields(params).inspect}"
|
92
|
+
# rubocop:enable Layout/LineLength
|
93
|
+
|
94
|
+
raise "Timeout waiting for response to command ID #{id}"
|
95
|
+
end
|
96
|
+
|
97
|
+
raise "Error response: #{response["error"]}" if response["error"]
|
98
|
+
|
99
|
+
result = response
|
100
|
+
|
101
|
+
result = yield response if block_given?
|
102
|
+
|
103
|
+
result
|
104
|
+
ensure
|
105
|
+
@pending_responses.delete(id)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# rubocop:enable Metrics/AbcSize
|
110
|
+
|
111
|
+
# Event API for external consumers
|
112
|
+
def on_message(&block) = @dispatcher.on_message(&block)
|
113
|
+
|
114
|
+
def on_open(&block) = @dispatcher.on_open(&block)
|
115
|
+
|
116
|
+
def on_close(&block) = @dispatcher.on_close(&block)
|
117
|
+
|
118
|
+
def on_error(&block) = @dispatcher.on_error(&block)
|
119
|
+
|
120
|
+
def on_event(*names, &block)
|
121
|
+
names.each do |name|
|
122
|
+
@dispatcher.on_event(name, &block)
|
123
|
+
end
|
124
|
+
|
125
|
+
send_cmd "session.subscribe", { events: names } if names.any?
|
126
|
+
end
|
127
|
+
|
128
|
+
def remove_message_listener(block) = @dispatcher.remove_message_listener(block)
|
129
|
+
|
130
|
+
def remove_event_listener(*names, &block)
|
131
|
+
names.each do |event_name|
|
132
|
+
@dispatcher.remove_event_listener(event_name, block)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def add_headers_interceptor(
|
137
|
+
context:,
|
138
|
+
url_patterns:,
|
139
|
+
headers:
|
140
|
+
)
|
141
|
+
send_cmd_and_wait("network.addIntercept", {
|
142
|
+
context: context,
|
143
|
+
phases: ["beforeRequestSent"],
|
144
|
+
urlPatterns: url_patterns
|
145
|
+
}) do |response|
|
146
|
+
id = response["result"]["intercept"]
|
147
|
+
Bidi2pdf.logger.debug "Interceptor added: #{id}"
|
148
|
+
|
149
|
+
AddHeadersInterceptor.new(id, headers, self).tap do |interceptor|
|
150
|
+
on_event "network.beforeRequestSent", &interceptor.method(:handle_event)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def add_auth_interceptor(
|
156
|
+
context:,
|
157
|
+
url_patterns:,
|
158
|
+
username:,
|
159
|
+
password:
|
160
|
+
)
|
161
|
+
send_cmd_and_wait("network.addIntercept", {
|
162
|
+
context: context,
|
163
|
+
phases: ["authRequired"],
|
164
|
+
urlPatterns: url_patterns
|
165
|
+
}) do |response|
|
166
|
+
id = response["result"]["intercept"]
|
167
|
+
Bidi2pdf.logger.debug "Interceptor added: #{id}"
|
168
|
+
|
169
|
+
AuthInterceptor.new(id, username, password, self).tap do |interceptor|
|
170
|
+
on_event "network.authRequired", &interceptor.method(:handle_event)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
private
|
176
|
+
|
177
|
+
def next_id
|
178
|
+
cmd_id = nil
|
179
|
+
|
180
|
+
@send_cmd_mutex.synchronize do
|
181
|
+
@id += 1
|
182
|
+
cmd_id = @id
|
183
|
+
@pending_responses[cmd_id] = Queue.new
|
184
|
+
end
|
185
|
+
|
186
|
+
cmd_id
|
187
|
+
end
|
188
|
+
|
189
|
+
def handle_open
|
190
|
+
@connection_mutex.synchronize do
|
191
|
+
@connected = true
|
192
|
+
@connection_cv.broadcast
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
def handle_response_to_cmd(data)
|
197
|
+
if (id = data["id"]) && @pending_responses.key?(id)
|
198
|
+
@pending_responses[id]&.push(data)
|
199
|
+
elsif (data = data["error"])
|
200
|
+
Bidi2pdf.logger.error "Error response: #{data.inspect}"
|
201
|
+
else
|
202
|
+
Bidi2pdf.logger.warn "Unknown response: #{data.inspect}"
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def redact_sensitive_fields(obj, sensitive_keys = %w[value token password authorization username])
|
207
|
+
case obj
|
208
|
+
when Hash
|
209
|
+
obj.each_with_object({}) do |(k, v), result|
|
210
|
+
result[k] = if sensitive_keys.include?(k.to_s.downcase)
|
211
|
+
"[REDACTED]"
|
212
|
+
else
|
213
|
+
redact_sensitive_fields(v, sensitive_keys)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
when Array
|
217
|
+
obj.map { |item| redact_sensitive_fields(item, sensitive_keys) }
|
218
|
+
else
|
219
|
+
obj
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bidi2pdf
|
4
|
+
module Bidi
|
5
|
+
class EventManager
|
6
|
+
attr_reader :type
|
7
|
+
|
8
|
+
def initialize(type)
|
9
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
10
|
+
@type = type
|
11
|
+
end
|
12
|
+
|
13
|
+
def on(*event_names, &block)
|
14
|
+
event_names.each { |event_name| @listeners[event_name.to_sym] << block }
|
15
|
+
|
16
|
+
block
|
17
|
+
end
|
18
|
+
|
19
|
+
def off(event_name, block) = @listeners[event_name.to_sym].delete(block)
|
20
|
+
|
21
|
+
def dispatch(event_name, *args)
|
22
|
+
listeners = @listeners[event_name.to_sym] || []
|
23
|
+
|
24
|
+
if event_name.to_s.include?(".")
|
25
|
+
toplevel_event_name = event_name.to_s.split(".").first
|
26
|
+
listeners += @listeners[toplevel_event_name.to_sym]
|
27
|
+
end
|
28
|
+
|
29
|
+
log_msg("Dispatching #{type} '#{event_name}' to #{listeners.size} listeners", args)
|
30
|
+
|
31
|
+
listeners.each { |listener| listener.call(*args) }
|
32
|
+
end
|
33
|
+
|
34
|
+
def clear(event_name = nil)
|
35
|
+
if event_name
|
36
|
+
@listeners[event_name].clear
|
37
|
+
else
|
38
|
+
@listeners.clear
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def log_msg(prefix, data)
|
43
|
+
message = truncate_large_values(data)
|
44
|
+
Bidi2pdf.logger.debug "#{prefix}: #{message.inspect}"
|
45
|
+
end
|
46
|
+
|
47
|
+
# rubocop: disable all
|
48
|
+
def truncate_large_values(org, max_length = 50, max_depth = 5, current_depth = 0)
|
49
|
+
return "...(too deep)..." if current_depth >= max_depth
|
50
|
+
|
51
|
+
obj = org.dup
|
52
|
+
|
53
|
+
case obj
|
54
|
+
when Hash
|
55
|
+
obj.each_with_object({}) do |(k, v), result|
|
56
|
+
result[k] = if %w[username password].include?(k.to_s.downcase)
|
57
|
+
"[REDACTED]"
|
58
|
+
else
|
59
|
+
truncate_large_values(v, max_length, max_depth, current_depth + 1)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
when Array
|
63
|
+
if obj.size > 10
|
64
|
+
obj.take(10).map do |v|
|
65
|
+
truncate_large_values(v, max_length, max_depth, current_depth + 1)
|
66
|
+
end + ["...(#{obj.size - 10} more items)"]
|
67
|
+
else
|
68
|
+
obj.map { |v| truncate_large_values(v, max_length, max_depth, current_depth + 1) }
|
69
|
+
end
|
70
|
+
when String
|
71
|
+
if obj.length > max_length
|
72
|
+
"#{obj[0...max_length]}... (truncated, total length: #{obj.length})"
|
73
|
+
else
|
74
|
+
obj
|
75
|
+
end
|
76
|
+
else
|
77
|
+
obj
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# rubocop: enable all
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bidi2pdf
|
4
|
+
module Bidi
|
5
|
+
class NetworkEvent
|
6
|
+
attr_reader :id, :url, :state, :start_timestamp, :end_timestamp, :timing
|
7
|
+
|
8
|
+
STATE_MAP = {
|
9
|
+
"network.responseStarted" => "started",
|
10
|
+
"network.responseCompleted" => "completed",
|
11
|
+
"network.fetchError" => "error"
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
def initialize(id:, url:, timestamp:, timing:, state:)
|
15
|
+
@id = id
|
16
|
+
@url = url
|
17
|
+
@start_timestamp = timestamp
|
18
|
+
@timing = timing
|
19
|
+
@state = map_state(state)
|
20
|
+
end
|
21
|
+
|
22
|
+
def update_state(new_state, timestamp: nil, timing: nil)
|
23
|
+
@state = map_state(new_state)
|
24
|
+
@end_timestamp = timestamp if timestamp
|
25
|
+
@timing = timing if timing
|
26
|
+
end
|
27
|
+
|
28
|
+
def map_state(state)
|
29
|
+
STATE_MAP.fetch(state, state)
|
30
|
+
end
|
31
|
+
|
32
|
+
def format_timestamp(timestamp)
|
33
|
+
return "N/A" unless timestamp
|
34
|
+
|
35
|
+
Time.at(timestamp / 1000.0).utc.strftime("%Y-%m-%d %H:%M:%S.%L UTC")
|
36
|
+
end
|
37
|
+
|
38
|
+
def duration_seconds
|
39
|
+
return nil unless @start_timestamp && @end_timestamp
|
40
|
+
|
41
|
+
((@end_timestamp - @start_timestamp) / 1000.0).round(3)
|
42
|
+
end
|
43
|
+
|
44
|
+
def in_progress? = state == "started"
|
45
|
+
|
46
|
+
def to_s
|
47
|
+
took_str = duration_seconds ? "took #{duration_seconds} sec" : "in progress"
|
48
|
+
"#<NetworkEvent id=#{@id} url=#{@url} state=#{@state} " \
|
49
|
+
"start=#{format_timestamp(@start_timestamp)} " \
|
50
|
+
"end=#{format_timestamp(@end_timestamp)} #{took_str}>"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "network_event"
|
4
|
+
|
5
|
+
module Bidi2pdf
|
6
|
+
module Bidi
|
7
|
+
class NetworkEvents
|
8
|
+
attr_reader :context_id, :events
|
9
|
+
|
10
|
+
def initialize(context_id)
|
11
|
+
@context_id = context_id
|
12
|
+
@events = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
def handle_event(data)
|
16
|
+
event = data["params"]
|
17
|
+
method = data["method"]
|
18
|
+
|
19
|
+
if event["context"] == context_id
|
20
|
+
handle_response(method, event)
|
21
|
+
else
|
22
|
+
Bidi2pdf.logger.debug "Ignoring Network event: #{method}, #{context_id}, params: #{event}"
|
23
|
+
end
|
24
|
+
rescue StandardError => e
|
25
|
+
Bidi2pdf.logger.error "Error handling network event: #{e.message}"
|
26
|
+
end
|
27
|
+
|
28
|
+
# rubocop:disable Metrics/AbcSize
|
29
|
+
def handle_response(method, event)
|
30
|
+
return unless event && event["request"]
|
31
|
+
|
32
|
+
request = event["request"]
|
33
|
+
|
34
|
+
id = request["request"]
|
35
|
+
url = request["url"]
|
36
|
+
timing = request["timings"]
|
37
|
+
|
38
|
+
timestamp = event["timestamp"]
|
39
|
+
|
40
|
+
if method == "network.responseStarted"
|
41
|
+
events[id] ||= NetworkEvent.new(
|
42
|
+
id: id,
|
43
|
+
url: url,
|
44
|
+
timestamp: timestamp,
|
45
|
+
timing: timing,
|
46
|
+
state: method
|
47
|
+
)
|
48
|
+
elsif events.key?(id)
|
49
|
+
events[id].update_state(method, timestamp: timestamp, timing: timing)
|
50
|
+
else
|
51
|
+
Bidi2pdf.logger.warn "Received response for unknown request ID: #{id}, URL: #{url}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# rubocop:enable Metrics/AbcSize
|
56
|
+
|
57
|
+
def all_events
|
58
|
+
events.values.sort_by(&:start_timestamp)
|
59
|
+
end
|
60
|
+
|
61
|
+
def wait_until_all_finished(timeout: 10, poll_interval: 0.1)
|
62
|
+
start_time = Time.now
|
63
|
+
|
64
|
+
loop do
|
65
|
+
unless events.values.any?(&:in_progress?)
|
66
|
+
Bidi2pdf.logger.debug "✅ All network events completed."
|
67
|
+
break
|
68
|
+
end
|
69
|
+
|
70
|
+
if Time.now - start_time > timeout
|
71
|
+
# rubocop:disable Layout/LineLength
|
72
|
+
Bidi2pdf.logger.warn "⏰ Timeout while waiting for network events to complete. Still in progress: #{in_progress.map(&:id)}"
|
73
|
+
# rubocop:enable Layout/LineLength
|
74
|
+
break
|
75
|
+
end
|
76
|
+
|
77
|
+
sleep(poll_interval)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bidi2pdf
|
4
|
+
module Bidi
|
5
|
+
# Validates parameters for the BiDi method `browsingContext.print`.
|
6
|
+
#
|
7
|
+
# Allowed structure of the params hash:
|
8
|
+
#
|
9
|
+
# {
|
10
|
+
# background: Boolean (optional, default: false) – print background graphics,
|
11
|
+
# margin: {
|
12
|
+
# top: Float >= 0.0 (optional, default: 1.0),
|
13
|
+
# bottom: Float >= 0.0 (optional, default: 1.0),
|
14
|
+
# left: Float >= 0.0 (optional, default: 1.0),
|
15
|
+
# right: Float >= 0.0 (optional, default: 1.0)
|
16
|
+
# },
|
17
|
+
# orientation: "portrait" or "landscape" (optional, default: "portrait"),
|
18
|
+
# page: {
|
19
|
+
# width: Float >= 0.0352 (optional, default: 21.59),
|
20
|
+
# height: Float >= 0.0352 (optional, default: 27.94)
|
21
|
+
# },
|
22
|
+
# pageRanges: Array of Integers or Strings (optional),
|
23
|
+
# scale: Float between 0.1 and 2.0 (optional, default: 1.0),
|
24
|
+
# shrinkToFit: Boolean (optional, default: true)
|
25
|
+
# }
|
26
|
+
#
|
27
|
+
# This validator checks presence, types, allowed ranges, and values,
|
28
|
+
# and raises ArgumentError with a descriptive message if validation fails.
|
29
|
+
class PrintParametersValidator
|
30
|
+
def self.validate!(params)
|
31
|
+
new(params).validate!
|
32
|
+
end
|
33
|
+
|
34
|
+
def initialize(params)
|
35
|
+
@params = params
|
36
|
+
end
|
37
|
+
|
38
|
+
def validate!
|
39
|
+
raise ArgumentError, "params must be a Hash" unless @params.is_a?(Hash)
|
40
|
+
|
41
|
+
validate_boolean(:background)
|
42
|
+
validate_boolean(:shrinkToFit)
|
43
|
+
validate_orientation
|
44
|
+
validate_scale
|
45
|
+
validate_page_ranges
|
46
|
+
validate_margin
|
47
|
+
validate_page_size
|
48
|
+
|
49
|
+
true
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def validate_boolean(key)
|
55
|
+
return unless @params.key?(key)
|
56
|
+
return if [true, false].include?(@params[key])
|
57
|
+
|
58
|
+
raise ArgumentError, ":#{key} must be a boolean"
|
59
|
+
end
|
60
|
+
|
61
|
+
def validate_orientation
|
62
|
+
return unless @params.key?(:orientation)
|
63
|
+
return if %w[portrait landscape].include?(@params[:orientation])
|
64
|
+
|
65
|
+
raise ArgumentError, ":orientation must be 'portrait' or 'landscape'"
|
66
|
+
end
|
67
|
+
|
68
|
+
def validate_scale
|
69
|
+
return unless @params.key?(:scale)
|
70
|
+
|
71
|
+
scale = @params[:scale]
|
72
|
+
return if scale.is_a?(Numeric) && scale >= 0.1 && scale <= 2.0
|
73
|
+
|
74
|
+
raise ArgumentError, ":scale must be a number between 0.1 and 2.0"
|
75
|
+
end
|
76
|
+
|
77
|
+
def validate_page_ranges
|
78
|
+
return unless @params.key?(:pageRanges)
|
79
|
+
unless @params[:pageRanges].is_a?(Array) &&
|
80
|
+
@params[:pageRanges].all? { |v| v.is_a?(Integer) || v.is_a?(String) }
|
81
|
+
raise ArgumentError, ":pageRanges must be an array of integers or strings"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def validate_margin
|
86
|
+
return unless @params.key?(:margin)
|
87
|
+
|
88
|
+
margin = @params[:margin]
|
89
|
+
raise ArgumentError, ":margin must be a Hash" unless margin.is_a?(Hash)
|
90
|
+
|
91
|
+
%i[top bottom left right].each do |side|
|
92
|
+
next unless margin.key?(side)
|
93
|
+
|
94
|
+
val = margin[side]
|
95
|
+
raise ArgumentError, "margin[:#{side}] must be a float >= 0.0" unless val.is_a?(Numeric) && val >= 0.0
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def validate_page_size
|
100
|
+
return unless @params.key?(:page)
|
101
|
+
|
102
|
+
page = @params[:page]
|
103
|
+
raise ArgumentError, ":page must be a Hash" unless page.is_a?(Hash)
|
104
|
+
|
105
|
+
%i[width height].each do |dim|
|
106
|
+
next unless page.key?(dim)
|
107
|
+
|
108
|
+
val = page[dim]
|
109
|
+
raise ArgumentError, "page[:#{dim}] must be a float >= 0.0352" unless val.is_a?(Numeric) && val >= 0.0352
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "net/http"
|
4
|
+
require "uri"
|
5
|
+
require "json"
|
6
|
+
require_relative "client"
|
7
|
+
require_relative "browser"
|
8
|
+
require_relative "user_context"
|
9
|
+
|
10
|
+
module Bidi2pdf
|
11
|
+
module Bidi
|
12
|
+
class Session
|
13
|
+
SUBSCRIBE_EVENTS = [
|
14
|
+
"browsingContext",
|
15
|
+
"network",
|
16
|
+
"log",
|
17
|
+
"script",
|
18
|
+
"goog:cdp.Debugger.scriptParsed",
|
19
|
+
"goog:cdp.CSS.styleSheetAdded",
|
20
|
+
"goog:cdp.Runtime.executionContextsCleared",
|
21
|
+
# Tracing
|
22
|
+
"goog:cdp.Tracing.tracingComplete",
|
23
|
+
"goog:cdp.Network.requestWillBeSent",
|
24
|
+
"goog:cdp.Debugger.scriptParsed",
|
25
|
+
"goog:cdp.Page.screencastFrame"
|
26
|
+
].freeze
|
27
|
+
|
28
|
+
attr_reader :port, :websocket_url
|
29
|
+
|
30
|
+
def initialize(port, headless: true)
|
31
|
+
@port = port
|
32
|
+
@headless = headless
|
33
|
+
@client = nil
|
34
|
+
@browser = nil
|
35
|
+
@websocket_url = nil
|
36
|
+
end
|
37
|
+
|
38
|
+
def start
|
39
|
+
client
|
40
|
+
end
|
41
|
+
|
42
|
+
def client
|
43
|
+
@client ||= create_client
|
44
|
+
end
|
45
|
+
|
46
|
+
def close
|
47
|
+
client&.send_cmd_and_wait("session.end", {}) do |response|
|
48
|
+
Bidi2pdf.logger.debug "Session ended: #{response}"
|
49
|
+
@client = nil
|
50
|
+
@websocket_url = nil
|
51
|
+
@browser = nil
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def browser
|
56
|
+
@browser ||= create_browser
|
57
|
+
end
|
58
|
+
|
59
|
+
def user_contexts
|
60
|
+
client&.send_cmd_and_wait("browser.getUserContexts", {}) do |response|
|
61
|
+
Bidi2pdf.logger.debug "User contexts: #{response}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def status
|
66
|
+
client&.send_cmd_and_wait("session.status", {}) do |response|
|
67
|
+
Bidi2pdf.logger.info "Session status: #{response.inspect}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def create_browser
|
74
|
+
start
|
75
|
+
|
76
|
+
@client.start
|
77
|
+
@client.wait_until_open
|
78
|
+
|
79
|
+
Bidi2pdf.logger.info "Subscribing to events"
|
80
|
+
|
81
|
+
event_client = Bidi::Client.new(websocket_url).tap(&:start)
|
82
|
+
event_client.wait_until_open
|
83
|
+
|
84
|
+
event_client.on_event(*SUBSCRIBE_EVENTS) do |data|
|
85
|
+
Bidi2pdf.logger.debug "Received event: #{data["method"]}, params: #{data["params"]}"
|
86
|
+
end
|
87
|
+
|
88
|
+
Bidi::Browser.new(@client)
|
89
|
+
end
|
90
|
+
|
91
|
+
# rubocop: disable Metrics/AbcSize
|
92
|
+
def create_client
|
93
|
+
uri = URI("http://localhost:#{port}/session")
|
94
|
+
args = %w[
|
95
|
+
--disable-gpu
|
96
|
+
--disable-popup-blocking
|
97
|
+
--disable-hang-monitor
|
98
|
+
]
|
99
|
+
|
100
|
+
args << "--headless" if @headless
|
101
|
+
|
102
|
+
session_request = {
|
103
|
+
"capabilities" => {
|
104
|
+
"alwaysMatch" => {
|
105
|
+
"browserName" => "chrome",
|
106
|
+
"goog:chromeOptions" => {
|
107
|
+
"args" => args
|
108
|
+
},
|
109
|
+
"goog:prerenderingDisabled" => true,
|
110
|
+
"unhandledPromptBehavior" => {
|
111
|
+
default: "ignore"
|
112
|
+
},
|
113
|
+
"acceptInsecureCerts" => true,
|
114
|
+
"webSocketUrl" => true
|
115
|
+
}
|
116
|
+
}
|
117
|
+
}
|
118
|
+
response = Net::HTTP.post(uri, session_request.to_json, "Content-Type" => "application/json")
|
119
|
+
session_data = JSON.parse(response.body)
|
120
|
+
|
121
|
+
Bidi2pdf.logger.debug "Session data: #{session_data}"
|
122
|
+
|
123
|
+
session_id = session_data["value"]["sessionId"]
|
124
|
+
@websocket_url = session_data["value"]["capabilities"]["webSocketUrl"]
|
125
|
+
|
126
|
+
Bidi2pdf.logger.info "Created session with ID: #{session_id}"
|
127
|
+
Bidi2pdf.logger.info "WebSocket URL: #{@websocket_url}"
|
128
|
+
|
129
|
+
Bidi::Client.new(@websocket_url).tap(&:start)
|
130
|
+
end
|
131
|
+
|
132
|
+
# rubocop: enable Metrics/AbcSize
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|