puppeteer-ruby 0.0.14 → 0.0.19

Sign up to get free protection for your applications and to get access to all the features.
@@ -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