puppeteer-ruby 0.0.14 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ require 'mime/types'
2
+
3
+ class Puppeteer::Page
4
+ # /**
5
+ # * @typedef {Object} PDFOptions
6
+ # * @property {number=} scale
7
+ # * @property {boolean=} displayHeaderFooter
8
+ # * @property {string=} headerTemplate
9
+ # * @property {string=} footerTemplate
10
+ # * @property {boolean=} printBackground
11
+ # * @property {boolean=} landscape
12
+ # * @property {string=} pageRanges
13
+ # * @property {string=} format
14
+ # * @property {string|number=} width
15
+ # * @property {string|number=} height
16
+ # * @property {boolean=} preferCSSPageSize
17
+ # * @property {!{top?: string|number, bottom?: string|number, left?: string|number, right?: string|number}=} margin
18
+ # * @property {string=} path
19
+ # */
20
+ class PDFOptions
21
+ # @params options [Hash]
22
+ def initialize(options)
23
+ unless options[:path]
24
+ # Original puppeteer allows path = nil, however nothing to do without path actually.
25
+ # Also in most case, users forget to specify path parameter. So let's raise ArgumentError.
26
+ raise ArgumentError('"path" parameter must be specified.')
27
+ end
28
+
29
+ @scale = options[:scale]
30
+ @display_header_footer = options[:display_header_footer]
31
+ @header_template = options[:header_template]
32
+ @footer_template = options[:footer_template]
33
+ @print_background = options[:print_background]
34
+ @landscape = options[:landscape]
35
+ @page_ranges = options[:page_ranges]
36
+ @format = options[:format]
37
+ @width = options[:width]
38
+ @height = options[:height]
39
+ @prefer_css_page_size = options[:prefer_css_page_size]
40
+ @margin = Margin.new(options[:margin] || {})
41
+ @path = options[:path]
42
+ end
43
+
44
+ attr_reader :path
45
+
46
+ class PaperSize
47
+ def initialize(width:, height:)
48
+ @width = width
49
+ @height = height
50
+ end
51
+ attr_reader :width, :height
52
+ end
53
+
54
+ PAPER_FORMATS = {
55
+ letter: PaperSize.new(width: 8.5, height: 11),
56
+ legal: PaperSize.new(width: 8.5, height: 14),
57
+ tabloid: PaperSize.new(width: 11, height: 17),
58
+ ledger: PaperSize.new(width: 17, height: 11),
59
+ a0: PaperSize.new(width: 33.1, height: 46.8),
60
+ a1: PaperSize.new(width: 23.4, height: 33.1),
61
+ a2: PaperSize.new(width: 16.54, height: 23.4),
62
+ a3: PaperSize.new(width: 11.7, height: 16.54),
63
+ a4: PaperSize.new(width: 8.27, height: 11.7),
64
+ a5: PaperSize.new(width: 5.83, height: 8.27),
65
+ a6: PaperSize.new(width: 4.13, height: 5.83),
66
+ }
67
+
68
+ UNIT_TO_PIXELS = {
69
+ px: 1,
70
+ in: 96,
71
+ cm: 37.8,
72
+ mm: 3.78,
73
+ }
74
+
75
+ # @param parameter [String|Integer|nil]
76
+ private def convert_print_parameter_to_inches(parameter)
77
+ return nil if parameter.nil?
78
+
79
+ pixels =
80
+ if parameter.is_a?(Numeric)
81
+ parameter.to_i
82
+ elsif parameter.is_a?(String)
83
+ unit = parameter[-2..-1].downcase
84
+ value =
85
+ if UNIT_TO_PIXELS.has_key?(unit)
86
+ parameter[0...-2].to_i
87
+ else
88
+ unit = 'px'
89
+ parameter.to_i
90
+ end
91
+
92
+ value * UNIT_TO_PIXELS[unit]
93
+ else
94
+ raise ArgumentError.new("page.pdf() Cannot handle parameter type: #{parameter.class}")
95
+ end
96
+
97
+ pixels / 96
98
+ end
99
+
100
+ private def paper_size
101
+ @paper_size ||= calc_paper_size
102
+ end
103
+
104
+ # @return [PaperSize]
105
+ private def calc_paper_size
106
+ if @format
107
+ PAPER_FORMATS[@format.downcase] or raise ArgumentError.new("Unknown paper format: #{@format}")
108
+ else
109
+ PaperSize.new(
110
+ width: convert_print_parameter_to_inches(@width) || 8.5,
111
+ height: convert_print_parameter_to_inches(@height) || 11.0,
112
+ )
113
+ end
114
+ end
115
+
116
+ class Margin
117
+ def initialize(options)
118
+ @top = options[:top]
119
+ @bottom = options[:bottom]
120
+ @left = options[:left]
121
+ @right = options[:right]
122
+ end
123
+
124
+ def translate(&block)
125
+ new_margin ={
126
+ top: block.call(@top),
127
+ bottom: block.call(@bottom),
128
+ left: block.call(@left),
129
+ right: block.call(@right),
130
+ }
131
+ Margin.new(new_margin)
132
+ end
133
+ attr_reader :top, :bottom, :left, :right
134
+ end
135
+
136
+ private def margin
137
+ @__margin ||= calc_margin
138
+ end
139
+
140
+ private def calc_margin
141
+ @margin.translate do |value|
142
+ convert_print_parameter_to_inches(value) || 0
143
+ end
144
+ end
145
+
146
+ def page_print_args
147
+ {
148
+ transferMode: 'ReturnAsStream',
149
+ landscape: @landscape || false,
150
+ displayHeaderFooter: @display_header_footer || false,
151
+ headerTemplate: @header_template || '',
152
+ footerTemplate: @footer_template || '',
153
+ printBackground: @print_background || false,
154
+ scale: @scale || 1,
155
+ paperWidth: paper_size.width,
156
+ paperHeight: paper_size.height,
157
+ marginTop: margin.top,
158
+ marginBottom: margin.bottom,
159
+ marginLeft: margin.left,
160
+ marginRight: margin.right,
161
+ pageRanges: @page_ranges || '',
162
+ preferCSSPageSize: @prefer_css_page_size || false,
163
+ }
164
+ end
165
+ end
166
+ end
@@ -6,6 +6,7 @@ class Puppeteer::RemoteObject
6
6
  # @param payload [Hash]
7
7
  def initialize(payload)
8
8
  @object_id = payload['objectId']
9
+ @type = payload['type']
9
10
  @sub_type = payload['subtype']
10
11
  @unserializable_value = payload['unserializableValue']
11
12
  @value = payload['value']
@@ -42,6 +43,21 @@ class Puppeteer::RemoteObject
42
43
  end
43
44
  end
44
45
 
46
+ # @return [String]
47
+ def type_str
48
+ # used in JSHandle#to_s
49
+ # original logic:
50
+ # if (this._remoteObject.objectId) {
51
+ # const type = this._remoteObject.subtype || this._remoteObject.type;
52
+ # return 'JSHandle@' + type;
53
+ # }
54
+ if @object_id
55
+ @sub_type || @type
56
+ else
57
+ nil
58
+ end
59
+ end
60
+
45
61
  # used in JSHandle#properties
46
62
  def properties(client)
47
63
  # original logic:
@@ -64,7 +80,18 @@ class Puppeteer::RemoteObject
64
80
 
65
81
  # used in ElementHandle#_box_model
66
82
  def box_model(client)
67
- client.send_message('DOM.getBoxModel', objectId: @object_id)
83
+ result = client.send_message('DOM.getBoxModel', objectId: @object_id)
84
+
85
+ # Firefox returns width/height = 0, content/padding/border/margin = [nil, nil, nil, nil, nil, nil, nil, nil]
86
+ # while Chrome throws Error(Could not compute box model)
87
+ model = result['model']
88
+ if model['width'] == 0 && model['height'] == 0 &&
89
+ %w(content padding border margin).all? { |key| model[key].all?(&:nil?) }
90
+
91
+ debug_puts('Could not compute box model in Firefox.')
92
+ return nil
93
+ end
94
+ result
68
95
  rescue => err
69
96
  debug_puts(err)
70
97
  nil
@@ -0,0 +1,330 @@
1
+ class Puppeteer::Request
2
+ include Puppeteer::DebugPrint
3
+ include Puppeteer::IfPresent
4
+
5
+ # defines some methods used only in NetworkManager, Response
6
+ class InternalAccessor
7
+ def initialize(request)
8
+ @request = request
9
+ end
10
+
11
+ def request_id
12
+ @request.instance_variable_get(:@request_id)
13
+ end
14
+
15
+ def interception_id
16
+ @request.instance_variable_get(:@interception_id)
17
+ end
18
+
19
+ # @param response [Puppeteer::Response]
20
+ def response=(response)
21
+ @request.instance_variable_set(:@response, response)
22
+ end
23
+
24
+ def redirect_chain
25
+ @request.instance_variable_get(:@redirect_chain)
26
+ end
27
+
28
+ def failure_text=(failure_text)
29
+ @request.instance_variable_set(:@failure_text, failure_text)
30
+ end
31
+
32
+ def from_memory_cache=(from_memory_cache)
33
+ @request.instance_variable_set(:@from_memory_cache, from_memory_cache)
34
+ end
35
+
36
+ def from_memory_cache?
37
+ @request.instance_variable_get(:@from_memory_cache)
38
+ end
39
+ end
40
+
41
+ # @param client [Puppeteer::CDPSession]
42
+ # @param frame [Puppeteer::Frame]
43
+ # @param interception_id [string|nil]
44
+ # @param allow_interception [boolean]
45
+ # @param event [Hash]
46
+ # @param redirect_chain Array<Request>
47
+ def initialize(client, frame, interception_id, allow_interception, event, redirect_chain)
48
+ @client = client
49
+ @request_id = event['requestId']
50
+ @is_navigation_request = event['requestId'] == event['loaderId'] && event['type'] == 'Document'
51
+ @interception_id = interception_id
52
+ @allow_interception = allow_interception
53
+ @url = event['request']['url']
54
+ @resource_type = event['type'].downcase
55
+ @method = event['request']['method']
56
+ @post_data = event['request']['postData']
57
+ @frame = frame
58
+ @redirect_chain = redirect_chain
59
+ @headers = {}
60
+ event['request']['headers'].each do |key, value|
61
+ @headers[key.downcase] = value
62
+ end
63
+ @from_memory_cache = false
64
+
65
+ @internal = InternalAccessor.new(self)
66
+ end
67
+
68
+ attr_reader :internal
69
+ attr_reader :url, :resource_type, :method, :post_data, :headers, :response, :frame
70
+
71
+ def navigation_request?
72
+ @is_navigation_request
73
+ end
74
+
75
+ def redirect_chain
76
+ @redirect_chain.dup
77
+ end
78
+
79
+ def failure
80
+ if_present(@failure_text) do |failure_text|
81
+ { errorText: @failure_text }
82
+ end
83
+ end
84
+
85
+ private def headers_to_array(headers)
86
+ return nil unless headers
87
+
88
+ headers.map do |key, value|
89
+ { name: key, value: value.to_s }
90
+ end
91
+ end
92
+
93
+ class InterceptionNotEnabledError < StandardError
94
+ def initialize
95
+ super('Request Interception is not enabled!')
96
+ end
97
+ end
98
+
99
+ class AlreadyHandledError < StandardError
100
+ def initialize
101
+ super('Request is already handled!')
102
+ end
103
+ end
104
+
105
+ # proceed request on request interception.
106
+ #
107
+ # Example:
108
+ #
109
+ # page.on 'request' do |req|
110
+ # # Override headers
111
+ # headers = req.headers.merge(
112
+ # foo: 'bar', # set "foo" header
113
+ # origin: nil, # remove "origin" header
114
+ # )
115
+ # req.continue(headers: headers)
116
+ # end
117
+ #
118
+ # @param error_code [String|Symbol]
119
+ def continue(url: nil, method: nil, post_data: nil, headers: nil)
120
+ # Request interception is not supported for data: urls.
121
+ return if @url.start_with?('data:')
122
+
123
+ unless @allow_interception
124
+ raise InterceptionNotEnabledError.new
125
+ end
126
+ if @interception_handled
127
+ raise AlreadyHandledError.new
128
+ end
129
+ @interception_handled = true
130
+
131
+ overrides = {
132
+ url: url,
133
+ method: method,
134
+ post_data: post_data,
135
+ headers: headers_to_array(headers),
136
+ }.compact
137
+ begin
138
+ @client.send_message('Fetch.continueRequest',
139
+ requestId: @interception_id,
140
+ **overrides,
141
+ )
142
+ rescue => err
143
+ # In certain cases, protocol will return error if the request was already canceled
144
+ # or the page was closed. We should tolerate these errors.
145
+ debug_puts(err)
146
+ end
147
+ end
148
+
149
+ # Mocking response.
150
+ #
151
+ # Example:
152
+ #
153
+ # page.on 'request' do |req|
154
+ # req.respond(
155
+ # status: 404,
156
+ # content_type: 'text/plain',
157
+ # body: 'Not Found!'
158
+ # )
159
+ # end
160
+ #
161
+ # @param status [Integer]
162
+ # @param headers [Hash<String, String>]
163
+ # @param content_type [String]
164
+ # @param body [String]
165
+ def respond(status: nil, headers: nil, content_type: nil, body: nil)
166
+ # Mocking responses for dataURL requests is not currently supported.
167
+ return if @url.start_with?('data:')
168
+
169
+ unless @allow_interception
170
+ raise InterceptionNotEnabledError.new
171
+ end
172
+ if @interception_handled
173
+ raise AlreadyHandledError.new
174
+ end
175
+ @interception_handled = true
176
+
177
+ mock_response_headers = {}
178
+ headers&.each do |key, value|
179
+ mock_response_headers[key.downcase] = value
180
+ end
181
+ if content_type
182
+ mock_response_headers['content-type'] = content_type
183
+ end
184
+ if body
185
+ mock_response_headers['content-length'] = body.length
186
+ end
187
+
188
+ mock_response = {
189
+ responseCode: status || 200,
190
+ responsePhrase: STATUS_TEXTS[(status || 200).to_s],
191
+ responseHeaders: headers_to_array(mock_response_headers),
192
+ body: if_present(body) { |mock_body| Base64.strict_encode64(mock_body) },
193
+ }.compact
194
+ begin
195
+ @client.send_message('Fetch.fulfillRequest',
196
+ requestId: @interception_id,
197
+ **mock_response,
198
+ )
199
+ rescue => err
200
+ # In certain cases, protocol will return error if the request was already canceled
201
+ # or the page was closed. We should tolerate these errors.
202
+ debug_puts(err)
203
+ end
204
+ end
205
+
206
+ # abort request on request interception.
207
+ #
208
+ # Example:
209
+ #
210
+ # page.on 'request' do |req|
211
+ # if req.url.include?("porn")
212
+ # req.abort
213
+ # else
214
+ # req.continue
215
+ # end
216
+ # end
217
+ #
218
+ # @param error_code [String|Symbol]
219
+ def abort(error_code: :failed)
220
+ # Request interception is not supported for data: urls.
221
+ return if @url.start_with?('data:')
222
+
223
+ error_reason = ERROR_REASONS[error_code.to_s]
224
+ unless error_reason
225
+ raise ArgumentError.new("Unknown error code: #{error_code}")
226
+ end
227
+ unless @allow_interception
228
+ raise InterceptionNotEnabledError.new
229
+ end
230
+ if @interception_handled
231
+ raise AlreadyHandledError.new
232
+ end
233
+ @interception_handled = true
234
+
235
+ begin
236
+ @client.send_message('Fetch.failRequest',
237
+ requestId: @interception_id,
238
+ errorReason: error_reason,
239
+ )
240
+ rescue => err
241
+ # In certain cases, protocol will return error if the request was already canceled
242
+ # or the page was closed. We should tolerate these errors.
243
+ debug_puts(err)
244
+ end
245
+ end
246
+
247
+ ERROR_REASONS = {
248
+ 'aborted' => 'Aborted',
249
+ 'accessdenied' => 'AccessDenied',
250
+ 'addressunreachable' => 'AddressUnreachable',
251
+ 'blockedbyclient' => 'BlockedByClient',
252
+ 'blockedbyresponse' => 'BlockedByResponse',
253
+ 'connectionaborted' => 'ConnectionAborted',
254
+ 'connectionclosed' => 'ConnectionClosed',
255
+ 'connectionfailed' => 'ConnectionFailed',
256
+ 'connectionrefused' => 'ConnectionRefused',
257
+ 'connectionreset' => 'ConnectionReset',
258
+ 'internetdisconnected' => 'InternetDisconnected',
259
+ 'namenotresolved' => 'NameNotResolved',
260
+ 'timedout' => 'TimedOut',
261
+ 'failed' => 'Failed',
262
+ }.freeze
263
+
264
+ # List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes.
265
+ STATUS_TEXTS = {
266
+ '100' => 'Continue',
267
+ '101' => 'Switching Protocols',
268
+ '102' => 'Processing',
269
+ '103' => 'Early Hints',
270
+ '200' => 'OK',
271
+ '201' => 'Created',
272
+ '202' => 'Accepted',
273
+ '203' => 'Non-Authoritative Information',
274
+ '204' => 'No Content',
275
+ '205' => 'Reset Content',
276
+ '206' => 'Partial Content',
277
+ '207' => 'Multi-Status',
278
+ '208' => 'Already Reported',
279
+ '226' => 'IM Used',
280
+ '300' => 'Multiple Choices',
281
+ '301' => 'Moved Permanently',
282
+ '302' => 'Found',
283
+ '303' => 'See Other',
284
+ '304' => 'Not Modified',
285
+ '305' => 'Use Proxy',
286
+ '306' => 'Switch Proxy',
287
+ '307' => 'Temporary Redirect',
288
+ '308' => 'Permanent Redirect',
289
+ '400' => 'Bad Request',
290
+ '401' => 'Unauthorized',
291
+ '402' => 'Payment Required',
292
+ '403' => 'Forbidden',
293
+ '404' => 'Not Found',
294
+ '405' => 'Method Not Allowed',
295
+ '406' => 'Not Acceptable',
296
+ '407' => 'Proxy Authentication Required',
297
+ '408' => 'Request Timeout',
298
+ '409' => 'Conflict',
299
+ '410' => 'Gone',
300
+ '411' => 'Length Required',
301
+ '412' => 'Precondition Failed',
302
+ '413' => 'Payload Too Large',
303
+ '414' => 'URI Too Long',
304
+ '415' => 'Unsupported Media Type',
305
+ '416' => 'Range Not Satisfiable',
306
+ '417' => 'Expectation Failed',
307
+ '418' => 'I\'m a teapot',
308
+ '421' => 'Misdirected Request',
309
+ '422' => 'Unprocessable Entity',
310
+ '423' => 'Locked',
311
+ '424' => 'Failed Dependency',
312
+ '425' => 'Too Early',
313
+ '426' => 'Upgrade Required',
314
+ '428' => 'Precondition Required',
315
+ '429' => 'Too Many Requests',
316
+ '431' => 'Request Header Fields Too Large',
317
+ '451' => 'Unavailable For Legal Reasons',
318
+ '500' => 'Internal Server Error',
319
+ '501' => 'Not Implemented',
320
+ '502' => 'Bad Gateway',
321
+ '503' => 'Service Unavailable',
322
+ '504' => 'Gateway Timeout',
323
+ '505' => 'HTTP Version Not Supported',
324
+ '506' => 'Variant Also Negotiates',
325
+ '507' => 'Insufficient Storage',
326
+ '508' => 'Loop Detected',
327
+ '510' => 'Not Extended',
328
+ '511' => 'Network Authentication Required',
329
+ }.freeze
330
+ end