puppeteer-ruby 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +36 -0
  5. data/.travis.yml +7 -0
  6. data/Dockerfile +6 -0
  7. data/Gemfile +6 -0
  8. data/README.md +41 -0
  9. data/Rakefile +1 -0
  10. data/bin/console +11 -0
  11. data/bin/setup +8 -0
  12. data/docker-compose.yml +15 -0
  13. data/example.rb +7 -0
  14. data/lib/puppeteer.rb +192 -0
  15. data/lib/puppeteer/async_await_behavior.rb +34 -0
  16. data/lib/puppeteer/browser.rb +240 -0
  17. data/lib/puppeteer/browser_context.rb +90 -0
  18. data/lib/puppeteer/browser_fetcher.rb +6 -0
  19. data/lib/puppeteer/browser_runner.rb +142 -0
  20. data/lib/puppeteer/cdp_session.rb +78 -0
  21. data/lib/puppeteer/concurrent_ruby_utils.rb +37 -0
  22. data/lib/puppeteer/connection.rb +254 -0
  23. data/lib/puppeteer/console_message.rb +24 -0
  24. data/lib/puppeteer/debug_print.rb +20 -0
  25. data/lib/puppeteer/device.rb +12 -0
  26. data/lib/puppeteer/devices.rb +885 -0
  27. data/lib/puppeteer/dom_world.rb +447 -0
  28. data/lib/puppeteer/element_handle.rb +433 -0
  29. data/lib/puppeteer/emulation_manager.rb +46 -0
  30. data/lib/puppeteer/errors.rb +4 -0
  31. data/lib/puppeteer/event_callbackable.rb +88 -0
  32. data/lib/puppeteer/execution_context.rb +230 -0
  33. data/lib/puppeteer/frame.rb +278 -0
  34. data/lib/puppeteer/frame_manager.rb +380 -0
  35. data/lib/puppeteer/if_present.rb +18 -0
  36. data/lib/puppeteer/js_handle.rb +142 -0
  37. data/lib/puppeteer/keyboard.rb +183 -0
  38. data/lib/puppeteer/keyboard/key_description.rb +19 -0
  39. data/lib/puppeteer/keyboard/us_keyboard_layout.rb +283 -0
  40. data/lib/puppeteer/launcher.rb +26 -0
  41. data/lib/puppeteer/launcher/base.rb +48 -0
  42. data/lib/puppeteer/launcher/browser_options.rb +41 -0
  43. data/lib/puppeteer/launcher/chrome.rb +165 -0
  44. data/lib/puppeteer/launcher/chrome_arg_options.rb +49 -0
  45. data/lib/puppeteer/launcher/launch_options.rb +68 -0
  46. data/lib/puppeteer/lifecycle_watcher.rb +168 -0
  47. data/lib/puppeteer/mouse.rb +120 -0
  48. data/lib/puppeteer/network_manager.rb +122 -0
  49. data/lib/puppeteer/page.rb +1001 -0
  50. data/lib/puppeteer/page/screenshot_options.rb +78 -0
  51. data/lib/puppeteer/remote_object.rb +124 -0
  52. data/lib/puppeteer/target.rb +150 -0
  53. data/lib/puppeteer/timeout_settings.rb +15 -0
  54. data/lib/puppeteer/touch_screen.rb +43 -0
  55. data/lib/puppeteer/version.rb +3 -0
  56. data/lib/puppeteer/viewport.rb +36 -0
  57. data/lib/puppeteer/wait_task.rb +6 -0
  58. data/lib/puppeteer/web_socket.rb +117 -0
  59. data/lib/puppeteer/web_socket_transport.rb +49 -0
  60. data/puppeteer-ruby.gemspec +29 -0
  61. metadata +213 -0
@@ -0,0 +1,90 @@
1
+ class Puppeteer::BrowserContext
2
+ include Puppeteer::EventCallbackable
3
+
4
+ # @param {!Puppeteer.Connection} connection
5
+ # @param {!Browser} browser
6
+ # @param {?string} contextId
7
+ def initialize(connection, browser, context_id)
8
+ @connection = connection
9
+ @browser = browser
10
+ @id = context_id
11
+ end
12
+
13
+ # @return {!Array<!Target>} target
14
+ def targets
15
+ @browser.targets.select{ |target| target.browser_context == self }
16
+ end
17
+
18
+ # @param {function(!Target):boolean} predicate
19
+ # @param {{timeout?: number}=} options
20
+ # @return {!Promise<!Target>}
21
+ def wait_for_target(predicate:, timeout: nil)
22
+ @browser.wait_for_target(
23
+ predicate: ->(target) { target.browser_context == self && predicate.call(target) },
24
+ timeout: timeout,
25
+ )
26
+ end
27
+
28
+ # @return {!Promise<!Array<!Puppeteer.Page>>}
29
+ def pages
30
+ targets.select{ |target| target.type == 'page' }.map(&:page).reject{ |page| !page }
31
+ end
32
+
33
+ def incognito?
34
+ !@id
35
+ end
36
+
37
+ # /**
38
+ # * @param {string} origin
39
+ # * @param {!Array<string>} permissions
40
+ # */
41
+ # async overridePermissions(origin, permissions) {
42
+ # const webPermissionToProtocol = new Map([
43
+ # ['geolocation', 'geolocation'],
44
+ # ['midi', 'midi'],
45
+ # ['notifications', 'notifications'],
46
+ # ['push', 'push'],
47
+ # ['camera', 'videoCapture'],
48
+ # ['microphone', 'audioCapture'],
49
+ # ['background-sync', 'backgroundSync'],
50
+ # ['ambient-light-sensor', 'sensors'],
51
+ # ['accelerometer', 'sensors'],
52
+ # ['gyroscope', 'sensors'],
53
+ # ['magnetometer', 'sensors'],
54
+ # ['accessibility-events', 'accessibilityEvents'],
55
+ # ['clipboard-read', 'clipboardRead'],
56
+ # ['clipboard-write', 'clipboardWrite'],
57
+ # ['payment-handler', 'paymentHandler'],
58
+ # // chrome-specific permissions we have.
59
+ # ['midi-sysex', 'midiSysex'],
60
+ # ]);
61
+ # permissions = permissions.map(permission => {
62
+ # const protocolPermission = webPermissionToProtocol.get(permission);
63
+ # if (!protocolPermission)
64
+ # throw new Error('Unknown permission: ' + permission);
65
+ # return protocolPermission;
66
+ # });
67
+ # await this._connection.send('Browser.grantPermissions', {origin, browserContextId: this._id || undefined, permissions});
68
+ # }
69
+
70
+ # async clearPermissionOverrides() {
71
+ # await this._connection.send('Browser.resetPermissions', {browserContextId: this._id || undefined});
72
+ # }
73
+
74
+ # @return [Future<Puppeteer::Page>]
75
+ def new_page
76
+ @browser.create_page_in_context(@id)
77
+ end
78
+
79
+ # @return [Browser]
80
+ def browser
81
+ @browser
82
+ end
83
+
84
+ def close
85
+ if !@id
86
+ raise 'Non-incognito profiles cannot be closed!'
87
+ end
88
+ @browser.dispose_context(@id)
89
+ end
90
+ end
@@ -0,0 +1,6 @@
1
+ # Download latest chromium.
2
+ class Puppeteer::BrowserFetcher
3
+ def initialize(project_root, options = {})
4
+ # 未実装
5
+ end
6
+ end
@@ -0,0 +1,142 @@
1
+ require 'fileutils'
2
+ require 'open3'
3
+ require 'timeout'
4
+
5
+ # https://github.com/puppeteer/puppeteer/blob/master/lib/Launcher.js
6
+ class Puppeteer::BrowserRunner
7
+ # @param {string} executablePath
8
+ # @param {!Array<string>} processArguments
9
+ # @param {string=} tempDirectory
10
+ def initialize(executable_path, process_arguments, temp_directory)
11
+ @executable_path = executable_path
12
+ @process_arguments = process_arguments
13
+ @temp_directory = temp_directory
14
+ @proc = nil
15
+ @connection = nil
16
+ @closed = true
17
+ @listeners = []
18
+ end
19
+
20
+ attr_reader :proc, :connection
21
+
22
+ class BrowserProcess
23
+ def initialize(env, executable_path, args)
24
+ stdin, @stdout, @stderr, @thread = Open3.popen3(env, executable_path, *args)
25
+ stdin.close
26
+ end
27
+
28
+ def dispose
29
+ [@stdout, @stderr].each{ |io| io.close unless io.closed? }
30
+ @thread.join
31
+ end
32
+
33
+ attr_reader :stdout, :stderr
34
+ end
35
+
36
+ # @param {!(Launcher.LaunchOptions)=} options
37
+ def start(
38
+ executable_path: nil,
39
+ ignore_default_args: nil,
40
+ handle_SIGINT: nil,
41
+ handle_SIGTERM: nil,
42
+ handle_SIGHUP: nil,
43
+ timeout: nil,
44
+ dumpio: nil,
45
+ env: nil,
46
+ pipe: nil
47
+ )
48
+ @launch_options = Puppeteer::Launcher::LaunchOptions.new({
49
+ executable_path: executable_path,
50
+ ignore_default_args: ignore_default_args,
51
+ handle_SIGINT: handle_SIGINT,
52
+ handle_SIGTERM: handle_SIGTERM,
53
+ handle_SIGHUP: handle_SIGHUP,
54
+ timeout: timeout,
55
+ dumpio: dumpio,
56
+ env: env,
57
+ pipe: pipe,
58
+ }.compact)
59
+ @proc = BrowserProcess.new(
60
+ @launch_options.env,
61
+ @executable_path,
62
+ @process_arguments,
63
+ )
64
+ # if (dumpio) {
65
+ # this.proc.stderr.pipe(process.stderr);
66
+ # this.proc.stdout.pipe(process.stdout);
67
+ # }
68
+ @closed = false
69
+ @process_closing = -> {
70
+ @proc.dispose
71
+ @closed = true
72
+ if @temp_directory
73
+ FileUtils.rm_rf(@temp_directory)
74
+ end
75
+ }
76
+ trap(:EXIT) do
77
+ kill
78
+ end
79
+
80
+ if @launch_options.handle_SIGINT?
81
+ trap(:INT) do
82
+ kill
83
+ exit 130
84
+ end
85
+ end
86
+
87
+ if @launch_options.handle_SIGTERM?
88
+ trap(:TERM) do
89
+ close
90
+ end
91
+ end
92
+
93
+ if @launch_options.handle_SIGHUP?
94
+ trap(:HUP) do
95
+ close
96
+ end
97
+ end
98
+ end
99
+
100
+ # @return {Promise}
101
+ def close
102
+ return if @closed
103
+
104
+ if @temp_directory
105
+ kill
106
+ elsif @connection
107
+ @connection.sendCommand("Browser.close")
108
+ end
109
+ end
110
+
111
+ # @return {Promise}
112
+ def kill
113
+ end
114
+
115
+
116
+ # @param {!({usePipe?: boolean, timeout: number, slowMo: number, preferredRevision: string})} options
117
+ # @return {!Promise<!Connection>}
118
+ def setup_connection(use_pipe:, timeout:, slow_mo:, preferred_revision:)
119
+ if !use_pipe
120
+ browser_ws_endpoint = wait_for_ws_endpoint(@proc, timeout, preferred_revision)
121
+ transport = Puppeteer::WebSocketTransport.create(browser_ws_endpoint)
122
+ @connection = Puppeteer::Connection.new(browser_ws_endpoint, transport, slow_mo)
123
+ else
124
+ raise NotImplementedError.new("PipeTransport is not yet implemented")
125
+ end
126
+
127
+ @connection
128
+ end
129
+
130
+ private def wait_for_ws_endpoint(browser_process, timeout, preferred_revision)
131
+ Timeout.timeout(timeout / 1000) do
132
+ loop do
133
+ line = browser_process.stderr.readline
134
+ /^DevTools listening on (ws:\/\/.*)$/.match(line) do |m|
135
+ return m[1]
136
+ end
137
+ end
138
+ end
139
+ rescue Timeout::Error
140
+ raise Puppeteer::TimeoutError.new("Timed out after #{timeout} ms while trying to connect to the browser! Only Chrome at revision r#{preferredRevision} is guaranteed to work.")
141
+ end
142
+ end
@@ -0,0 +1,78 @@
1
+ class Puppeteer::CDPSession
2
+ include Puppeteer::EventCallbackable
3
+ using Puppeteer::AsyncAwaitBehavior
4
+
5
+ class Error < StandardError ; end
6
+
7
+ # @param {!Connection} connection
8
+ # @param {string} targetType
9
+ # @param {string} sessionId
10
+ def initialize(connection, target_type, session_id)
11
+ @callbacks = {}
12
+ @connection = connection
13
+ @target_type = target_type
14
+ @session_id = session_id
15
+ end
16
+
17
+ attr_reader :connection
18
+
19
+ # @param method [String]
20
+ # @param params [Hash]
21
+ # @returns [Hash]
22
+ def send_message(method, params = {})
23
+ await async_send_message(method, params)
24
+ end
25
+
26
+ # @param method [String]
27
+ # @param params [Hash]
28
+ # @returns [Future<Hash>]
29
+ def async_send_message(method, params = {})
30
+ if !@connection
31
+ raise Error.new("Protocol error (#{method}): Session closed. Most likely the #{@target_type} has been closed.")
32
+ end
33
+ id = @connection.raw_send(message: { sessionId: @session_id, method: method, params: params })
34
+ promise = resolvable_future
35
+ @callbacks[id] = Puppeteer::Connection::MessageCallback.new(method: method, promise: promise)
36
+ promise
37
+ end
38
+
39
+ # @param {{id?: number, method: string, params: Object, error: {message: string, data: any}, result?: *}} object
40
+ def handle_message(message)
41
+ if message['id']
42
+ if callback = @callbacks.delete(message['id'])
43
+ if message['error']
44
+ callback.reject(
45
+ Puppeteer::Connection::ProtocolError.new(
46
+ method: callback.method,
47
+ error_message: response['error']['message'],
48
+ error_data: response['error']['data']))
49
+ else
50
+ callback.resolve(message['result'])
51
+ end
52
+ else
53
+ raise Error.new("unknown id: #{message['id']}")
54
+ end
55
+ else
56
+ emit_event message['method'], message['params']
57
+ end
58
+ end
59
+
60
+ def detach
61
+ if !@connection
62
+ raise Error.new("Session already detarched. Most likely the #{@target_type} has been closed.")
63
+ end
64
+ @connection.send_message('Target.detachFromTarget', sessionId: @session_id)
65
+ end
66
+
67
+ def handle_closed
68
+ @callbacks.values.each do |callback|
69
+ callback.reject(
70
+ Puppeteer::Connection::ProtocolError.new(
71
+ method: callback.method,
72
+ error_message: 'Target Closed.'))
73
+ end
74
+ @callbacks.clear
75
+ @connection = nil
76
+ emit_event 'Events.CDPSession.Disconnected'
77
+ end
78
+ end
@@ -0,0 +1,37 @@
1
+ # utility methods for Concurrent::Promises.
2
+ module Puppeteer::ConcurrentRubyUtils
3
+ def await_all(*args)
4
+ if args.length == 1 && args[0].is_a?(Enumerable)
5
+ Concurrent::Promises.zip(*(args[0])).value!
6
+ else
7
+ Concurrent::Promises.zip(*args).value!
8
+ end
9
+ end
10
+
11
+ def await_any(*args)
12
+ if args.length == 1 && args[0].is_a?(Enumerable)
13
+ Concurrent::Promises.any(*(args[0])).value!
14
+ else
15
+ Concurrent::Promises.any(*args).value!
16
+ end
17
+ end
18
+
19
+ # blocking get value of Future.
20
+ def await(future_or_value)
21
+ if future_or_value.is_a?(Concurrent::Promises::Future)
22
+ future_or_value.value!
23
+ else
24
+ future_or_value
25
+ end
26
+ end
27
+
28
+ def future(&block)
29
+ Concurrent::Promises.future(&block)
30
+ end
31
+
32
+ def resolvable_future
33
+ Concurrent::Promises.resolvable_future
34
+ end
35
+ end
36
+
37
+ include Puppeteer::ConcurrentRubyUtils
@@ -0,0 +1,254 @@
1
+ require 'json'
2
+
3
+ class Puppeteer::Connection
4
+ include Puppeteer::DebugPrint
5
+ include Puppeteer::EventCallbackable
6
+ using Puppeteer::AsyncAwaitBehavior
7
+
8
+ class ProtocolError < StandardError
9
+ def initialize(method:, error_message:, error_data: nil)
10
+ msg = "Protocol error (#{method}): #{error_message}"
11
+ if error_data
12
+ super("#{msg} #{error_data}")
13
+ else
14
+ super(msg)
15
+ end
16
+ end
17
+ end
18
+
19
+ # callback object stored in @callbacks.
20
+ class MessageCallback
21
+ # @param method [String]
22
+ # @param promise [Concurrent::Promises::ResolvableFuture]
23
+ def initialize(method:, promise:)
24
+ @method = method
25
+ @promise = promise
26
+ end
27
+
28
+ def resolve(result)
29
+ @promise.fulfill(result)
30
+ end
31
+
32
+ def reject(error)
33
+ @promise.reject(error)
34
+ end
35
+
36
+ attr_reader :method
37
+ end
38
+
39
+ def initialize(url, transport, delay = 0)
40
+ @url = url
41
+ @last_id = 0
42
+ @callbacks = {}
43
+ @delay = delay
44
+
45
+ @transport = transport
46
+ @transport.on_message do |data|
47
+ async_handle_message(JSON.parse(data))
48
+ end
49
+ @transport.on_close do |reason, code|
50
+ handle_close(reason, code)
51
+ end
52
+
53
+ @sessions = {}
54
+ @closed = false
55
+ end
56
+
57
+ def self.from_session(session)
58
+ session.connection
59
+ end
60
+
61
+ # @param {string} sessionId
62
+ # @return {?CDPSession}
63
+ def session(session_id)
64
+ @sessions[session_id]
65
+ end
66
+
67
+ def url
68
+ @url
69
+ end
70
+
71
+ # @param {string} method
72
+ # @param {!Object=} params
73
+ def send_message(method, params = {})
74
+ await async_send_message(method, params)
75
+ end
76
+
77
+ def async_send_message(method, params = {})
78
+ id = raw_send(message: { method: method, params: params })
79
+ promise = resolvable_future
80
+ @callbacks[id] = MessageCallback.new(method: method, promise: promise)
81
+ promise
82
+ end
83
+
84
+ private def generate_id
85
+ @last_id += 1
86
+ end
87
+
88
+ def raw_send(message:)
89
+ id = generate_id
90
+ payload = JSON.fast_generate(message.compact.merge(id: id))
91
+ @transport.send_text(payload)
92
+ request_debug_printer.handle_payload(payload)
93
+ id
94
+ end
95
+
96
+ # Just for effective debugging :)
97
+ class RequestDebugPrinter
98
+ include Puppeteer::DebugPrint
99
+
100
+ def handle_payload(payload)
101
+ debug_puts "SEND >> #{decorate(payload)}"
102
+ end
103
+
104
+ private def decorate(payload)
105
+ payload.gsub(/"method":"([^"]+)"/, "\"method\":\"\u001b[32m\\1\u001b[0m\"")
106
+ end
107
+ end
108
+
109
+ class ResponseDebugPrinter
110
+ include Puppeteer::DebugPrint
111
+
112
+ NON_DEBUG_PRINT_METHODS = [
113
+ 'Network.dataReceived',
114
+ 'Network.loadingFinished',
115
+ 'Network.requestWillBeSent',
116
+ 'Network.requestWillBeSentExtraInfo',
117
+ 'Network.responseReceived',
118
+ 'Network.responseReceivedExtraInfo',
119
+ 'Page.lifecycleEvent',
120
+ ]
121
+
122
+ def handle_message(message)
123
+ if skip_debug_print?(message['method'])
124
+ debug_print "."
125
+ @prev_log_skipped = true
126
+ else
127
+ debug_print "\n" if @prev_log_skipped
128
+ @prev_log_skipped = nil
129
+ debug_puts "RECV << #{decorate(message)}"
130
+ end
131
+ end
132
+
133
+ private def skip_debug_print?(method)
134
+ method && NON_DEBUG_PRINT_METHODS.include?(method)
135
+ end
136
+
137
+ private def decorate(message)
138
+ # decorate RED for error.
139
+ if message['error']
140
+ return "\u001b[31m#{message}\u001b[0m"
141
+ end
142
+
143
+ # ignore method call response, or with no method.
144
+ return message if message['id'] || !message['method']
145
+
146
+ # decorate cyan for method name.
147
+ message.to_s.gsub(message['method'], "\u001b[36m#{message['method']}\u001b[0m")
148
+ end
149
+ end
150
+
151
+ private def request_debug_printer
152
+ @request_debug_printer ||= RequestDebugPrinter.new
153
+ end
154
+
155
+ private def response_debug_printer
156
+ @response_debug_printer ||= ResponseDebugPrinter.new
157
+ end
158
+
159
+ private def handle_message(message)
160
+ if @delay > 0
161
+ sleep(@delay / 1000.0)
162
+ end
163
+
164
+ response_debug_printer.handle_message(message)
165
+
166
+ case message['method']
167
+ when 'Target.attachedToTarget'
168
+ session_id = message['params']['sessionId']
169
+ session = Puppeteer::CDPSession.new(self, message['params']['targetInfo']['type'], session_id)
170
+ @sessions[session_id] = session
171
+ when 'Target.detachedFromTarget'
172
+ session_id = message['params']['sessionId']
173
+ session = @sessions[session_id]
174
+ if session
175
+ session._onClosed
176
+ @sessions.delete(session_id)
177
+ end
178
+ end
179
+
180
+ if message['sessionId']
181
+ session_id = message['sessionId']
182
+ @sessions[session_id]&.handle_message(message)
183
+ elsif message['id']
184
+ # Callbacks could be all rejected if someone has called `.dispose()`.
185
+ if callback = @callbacks.delete(message['id'])
186
+ if message['error']
187
+ callback.reject(
188
+ ProtocolError.new(
189
+ method: callback.method,
190
+ error_message: response['error']['message'],
191
+ error_data: response['error']['data']))
192
+ else
193
+ callback.resolve(message["result"])
194
+ end
195
+ end
196
+ else
197
+ emit_event message['method'], message['params']
198
+ end
199
+ end
200
+
201
+ private async def async_handle_message(message)
202
+ handle_message(message)
203
+ end
204
+
205
+ private def handle_close
206
+ return if @closed
207
+ @closed = true
208
+ @transport.on_message
209
+ @transport.on_close
210
+ @callbacks.values.each do |callback|
211
+ callback.reject(
212
+ ProtocolError.new(
213
+ method: callback.method,
214
+ error_message: 'Target Closed.'))
215
+ end
216
+ @callbacks.clear
217
+ @sessions.values.each do |session|
218
+ session.handle_closed
219
+ end
220
+ @sessions.clear
221
+ emit_event 'Events.Connection.Disconnected'
222
+ end
223
+
224
+ def on_close(&block)
225
+ @on_close = block
226
+ end
227
+
228
+ def on_message(&block)
229
+ @on_message = block
230
+ end
231
+
232
+ def dispose
233
+ handle_close
234
+ @transport.close
235
+ end
236
+
237
+ # @param {Protocol.Target.TargetInfo} targetInfo
238
+ # @return [CDPSession]
239
+ def create_session(target_info)
240
+ result = send_message('Target.attachToTarget', targetId: target_info.target_id, flatten: true)
241
+ session_id = result['sessionId']
242
+
243
+ # Target.attachedToTarget is often notified after the result of Target.attachToTarget.
244
+ # D, [2020-04-04T23:04:30.736311 #91875] DEBUG -- : RECV << {"id"=>2, "result"=>{"sessionId"=>"DA002F8A95B04710502CB40D8430B95A"}}
245
+ # D, [2020-04-04T23:04:30.736649 #91875] DEBUG -- : RECV << {"method"=>"Target.attachedToTarget", "params"=>{"sessionId"=>"DA002F8A95B04710502CB40D8430B95A", "targetInfo"=>{"targetId"=>"EBAB949A7DE63F12CB94268AD3A9976B", "type"=>"page", "title"=>"about:blank", "url"=>"about:blank", "attached"=>true, "browserContextId"=>"46D23767E9B79DD9E589101121F6DADD"}, "waitingForDebugger"=>false}}
246
+ # So we have to wait for "Target.attachedToTarget" a bit.
247
+ 20.times do
248
+ if @sessions[session_id]
249
+ return @sessions[session_id]
250
+ end
251
+ sleep 0.1
252
+ end
253
+ end
254
+ end