ferrum 0.14 → 0.16

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.
@@ -47,6 +47,10 @@ module Ferrum
47
47
  [path] + @flags.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
48
48
  end
49
49
 
50
+ def to_s
51
+ to_a.join(" \\ \n ")
52
+ end
53
+
50
54
  private
51
55
 
52
56
  def merge_options
@@ -6,7 +6,6 @@ module Ferrum
6
6
  class Chrome < Base
7
7
  DEFAULT_OPTIONS = {
8
8
  "headless" => nil,
9
- "disable-gpu" => nil,
10
9
  "hide-scrollbars" => nil,
11
10
  "mute-audio" => nil,
12
11
  "enable-automation" => nil,
@@ -38,7 +37,8 @@ module Ferrum
38
37
  "metrics-recording-only" => nil,
39
38
  "safebrowsing-disable-auto-update" => nil,
40
39
  "password-store" => "basic",
41
- "no-startup-window" => nil
40
+ "no-startup-window" => nil,
41
+ "remote-allow-origins" => "*"
42
42
  # NOTE: --no-sandbox is not needed if you properly setup a user in the container.
43
43
  # https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
44
44
  # "no-sandbox" => nil,
@@ -63,7 +63,6 @@ module Ferrum
63
63
  def merge_required(flags, options, user_data_dir)
64
64
  flags = flags.merge("remote-debugging-port" => options.port,
65
65
  "remote-debugging-address" => options.host,
66
- # Doesn't work on MacOS, so we need to set it by CDP
67
66
  "window-size" => options.window_size&.join(","),
68
67
  "user-data-dir" => user_data_dir)
69
68
 
@@ -84,6 +83,12 @@ module Ferrum
84
83
  end
85
84
 
86
85
  defaults ||= DEFAULT_OPTIONS
86
+ # On Windows, the --disable-gpu flag is a temporary work around for a few bugs.
87
+ # See https://bugs.chromium.org/p/chromium/issues/detail?id=737678 for more information.
88
+ defaults = defaults.merge("disable-gpu" => nil) if Utils::Platform.windows?
89
+ # Use Metal on Apple Silicon
90
+ # https://github.com/google/angle#platform-support-via-backing-renderers
91
+ defaults = defaults.merge("use-angle" => "metal") if Utils::Platform.mac_arm?
87
92
  defaults.merge(flags)
88
93
  end
89
94
  end
@@ -3,7 +3,6 @@
3
3
  module Ferrum
4
4
  class Browser
5
5
  class Options
6
- HEADLESS = true
7
6
  BROWSER_PORT = "0"
8
7
  BROWSER_HOST = "127.0.0.1"
9
8
  WINDOW_SIZE = [1024, 768].freeze
@@ -12,55 +11,56 @@ module Ferrum
12
11
  PROCESS_TIMEOUT = ENV.fetch("FERRUM_PROCESS_TIMEOUT", 10).to_i
13
12
  DEBUG_MODE = !ENV.fetch("FERRUM_DEBUG", nil).nil?
14
13
 
15
- attr_reader :window_size, :timeout, :logger, :ws_max_receive_size,
14
+ attr_reader :window_size, :logger, :ws_max_receive_size,
16
15
  :js_errors, :base_url, :slowmo, :pending_connection_errors,
17
- :url, :env, :process_timeout, :browser_name, :browser_path,
18
- :save_path, :extensions, :proxy, :port, :host, :headless,
19
- :ignore_default_browser_options, :browser_options, :xvfb
16
+ :url, :ws_url, :env, :process_timeout, :browser_name, :browser_path,
17
+ :save_path, :proxy, :port, :host, :headless, :browser_options,
18
+ :ignore_default_browser_options, :xvfb, :flatten
19
+ attr_accessor :timeout, :default_user_agent
20
20
 
21
21
  def initialize(options = nil)
22
22
  @options = Hash(options&.dup)
23
+
23
24
  @port = @options.fetch(:port, BROWSER_PORT)
24
25
  @host = @options.fetch(:host, BROWSER_HOST)
25
26
  @timeout = @options.fetch(:timeout, DEFAULT_TIMEOUT)
26
27
  @window_size = @options.fetch(:window_size, WINDOW_SIZE)
27
28
  @js_errors = @options.fetch(:js_errors, false)
28
- @headless = @options.fetch(:headless, HEADLESS)
29
+ @headless = @options.fetch(:headless, true)
30
+ @flatten = @options.fetch(:flatten, true)
29
31
  @pending_connection_errors = @options.fetch(:pending_connection_errors, true)
30
32
  @process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT)
31
- @browser_options = @options.fetch(:browser_options, {})
32
33
  @slowmo = @options[:slowmo].to_f
33
34
 
34
- @ws_max_receive_size, @env, @browser_name, @browser_path,
35
- @save_path, @extensions, @ignore_default_browser_options, @xvfb = @options.values_at(
36
- :ws_max_receive_size, :env, :browser_name, :browser_path, :save_path, :extensions,
37
- :ignore_default_browser_options, :xvfb
38
- )
35
+ @env = @options[:env]
36
+ @xvfb = @options[:xvfb]
37
+ @save_path = @options[:save_path]
38
+ @browser_name = @options[:browser_name]
39
+ @browser_path = @options[:browser_path]
40
+ @ws_max_receive_size = @options[:ws_max_receive_size]
41
+ @ignore_default_browser_options = @options[:ignore_default_browser_options]
39
42
 
40
- @options[:window_size] = @window_size
41
- @proxy = parse_proxy(@options[:proxy])
43
+ @proxy = validate_proxy(@options[:proxy])
42
44
  @logger = parse_logger(@options[:logger])
43
45
  @base_url = parse_base_url(@options[:base_url]) if @options[:base_url]
44
46
  @url = @options[:url].to_s if @options[:url]
47
+ @ws_url = @options[:ws_url].to_s if @options[:ws_url]
45
48
 
46
- @options.freeze
47
- @browser_options.freeze
49
+ @options = @options.merge(window_size: @window_size).freeze
50
+ @browser_options = @options.fetch(:browser_options, {}).freeze
48
51
  end
49
52
 
50
- def to_h
51
- @options
53
+ def base_url=(value)
54
+ @base_url = parse_base_url(value)
52
55
  end
53
56
 
54
- def parse_base_url(value)
55
- parsed = Addressable::URI.parse(value)
56
- unless BASE_URL_SCHEMA.include?(parsed&.normalized_scheme)
57
- raise ArgumentError, "`base_url` should be absolute and include schema: #{BASE_URL_SCHEMA.join(' | ')}"
57
+ def extensions
58
+ @extensions ||= Array(@options[:extensions]).map do |extension|
59
+ (extension.is_a?(Hash) && extension[:source]) || File.read(extension)
58
60
  end
59
-
60
- parsed
61
61
  end
62
62
 
63
- def parse_proxy(options)
63
+ def validate_proxy(options)
64
64
  return unless options
65
65
 
66
66
  raise ArgumentError, "proxy options must be a Hash" unless options.is_a?(Hash)
@@ -72,6 +72,10 @@ module Ferrum
72
72
  options
73
73
  end
74
74
 
75
+ def to_h
76
+ @options
77
+ end
78
+
75
79
  private
76
80
 
77
81
  def parse_logger(logger)
@@ -79,6 +83,15 @@ module Ferrum
79
83
 
80
84
  !logger && DEBUG_MODE ? $stdout.tap { |s| s.sync = true } : logger
81
85
  end
86
+
87
+ def parse_base_url(value)
88
+ parsed = Addressable::URI.parse(value)
89
+ unless BASE_URL_SCHEMA.include?(parsed&.normalized_scheme)
90
+ raise ArgumentError, "`base_url` should be absolute and include schema: #{BASE_URL_SCHEMA.join(' | ')}"
91
+ end
92
+
93
+ parsed
94
+ end
82
95
  end
83
96
  end
84
97
  end
@@ -9,6 +9,8 @@ require "ferrum/browser/options/base"
9
9
  require "ferrum/browser/options/chrome"
10
10
  require "ferrum/browser/options/firefox"
11
11
  require "ferrum/browser/command"
12
+ require "ferrum/utils/elapsed_time"
13
+ require "ferrum/utils/platform"
12
14
 
13
15
  module Ferrum
14
16
  class Browser
@@ -62,11 +64,11 @@ module Ferrum
62
64
  def initialize(options)
63
65
  @pid = @xvfb = @user_data_dir = nil
64
66
 
65
- if options.url
66
- url = URI.join(options.url, "/json/version")
67
- response = JSON.parse(::Net::HTTP.get(url))
68
- self.ws_url = response["webSocketDebuggerUrl"]
69
- parse_browser_versions
67
+ if options.ws_url || options.url
68
+ # `:ws_url` option is higher priority than `:url`, parse versions
69
+ # and use it as a ws_url, otherwise use what has been parsed.
70
+ response = parse_json_version(options.ws_url || options.url)
71
+ self.ws_url = options.ws_url || response&.[]("webSocketDebuggerUrl")
70
72
  return
71
73
  end
72
74
 
@@ -100,7 +102,7 @@ module Ferrum
100
102
  ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
101
103
 
102
104
  parse_ws_url(read_io, @process_timeout)
103
- parse_browser_versions
105
+ parse_json_version(ws_url)
104
106
  ensure
105
107
  close_io(read_io, write_io)
106
108
  end
@@ -122,6 +124,17 @@ module Ferrum
122
124
  start
123
125
  end
124
126
 
127
+ def inspect
128
+ "#<#{self.class} " \
129
+ "@user_data_dir=#{@user_data_dir.inspect} " \
130
+ "@command=#<#{@command.class}:#{@command.object_id}> " \
131
+ "@default_user_agent=#{@default_user_agent.inspect} " \
132
+ "@ws_url=#{@ws_url.inspect} " \
133
+ "@v8_version=#{@v8_version.inspect} " \
134
+ "@browser_version=#{@browser_version.inspect} " \
135
+ "@webkit_version=#{@webkit_version.inspect}>"
136
+ end
137
+
125
138
  private
126
139
 
127
140
  def kill(pid)
@@ -163,25 +176,37 @@ module Ferrum
163
176
  @port = @ws_url.port
164
177
  end
165
178
 
166
- def parse_browser_versions
167
- return unless ws_url.is_a?(Addressable::URI)
179
+ def close_io(*ios)
180
+ ios.each do |io|
181
+ io.close unless io.closed?
182
+ rescue IOError
183
+ raise unless RUBY_ENGINE == "jruby"
184
+ end
185
+ end
168
186
 
169
- version_url = URI.parse(ws_url.merge(scheme: "http", path: "/json/version"))
170
- response = JSON.parse(::Net::HTTP.get(version_url))
187
+ def parse_json_version(url)
188
+ url = URI.join(url, "/json/version")
189
+
190
+ if %w[wss ws].include?(url.scheme)
191
+ url.scheme = case url.scheme
192
+ when "ws"
193
+ "http"
194
+ when "wss"
195
+ "https"
196
+ end
197
+ end
198
+
199
+ response = JSON.parse(::Net::HTTP.get(URI(url.to_s)))
171
200
 
172
201
  @v8_version = response["V8-Version"]
173
202
  @browser_version = response["Browser"]
174
203
  @webkit_version = response["WebKit-Version"]
175
204
  @default_user_agent = response["User-Agent"]
176
205
  @protocol_version = response["Protocol-Version"]
177
- end
178
206
 
179
- def close_io(*ios)
180
- ios.each do |io|
181
- io.close unless io.closed?
182
- rescue IOError
183
- raise unless RUBY_ENGINE == "jruby"
184
- end
207
+ response
208
+ rescue JSON::ParserError
209
+ # nop
185
210
  end
186
211
  end
187
212
  end
@@ -4,11 +4,11 @@ require "base64"
4
4
  require "forwardable"
5
5
  require "ferrum/page"
6
6
  require "ferrum/proxy"
7
+ require "ferrum/client"
7
8
  require "ferrum/contexts"
8
9
  require "ferrum/browser/xvfb"
9
10
  require "ferrum/browser/options"
10
11
  require "ferrum/browser/process"
11
- require "ferrum/browser/client"
12
12
  require "ferrum/browser/binary"
13
13
  require "ferrum/browser/version_info"
14
14
 
@@ -20,18 +20,20 @@ module Ferrum
20
20
  delegate %i[go_to goto go back forward refresh reload stop wait_for_reload
21
21
  at_css at_xpath css xpath current_url current_title url title
22
22
  body doctype content=
23
- headers cookies network
23
+ headers cookies network downloads
24
24
  mouse keyboard
25
25
  screenshot pdf mhtml viewport_size device_pixel_ratio
26
26
  frames frame_by main_frame
27
27
  evaluate evaluate_on evaluate_async execute evaluate_func
28
28
  add_script_tag add_style_tag bypass_csp
29
29
  on position position=
30
- playback_rate playback_rate=] => :page
31
- delegate %i[default_user_agent] => :process
30
+ playback_rate playback_rate=
31
+ disable_javascript set_viewport resize] => :page
32
32
 
33
- attr_reader :client, :process, :contexts, :options, :window_size, :base_url
34
- attr_accessor :timeout
33
+ attr_reader :client, :process, :contexts, :options
34
+
35
+ delegate %i[timeout timeout= base_url base_url= default_user_agent default_user_agent= extensions] => :options
36
+ delegate %i[command] => :client
35
37
 
36
38
  #
37
39
  # Initializes the browser.
@@ -45,6 +47,9 @@ module Ferrum
45
47
  # @option options [Boolean] :xvfb (false)
46
48
  # Run browser in a virtual framebuffer.
47
49
  #
50
+ # @option options [Boolean] :flatten (true)
51
+ # Use one websocket connection to the browser and all the pages in flatten mode.
52
+ #
48
53
  # @option options [(Integer, Integer)] :window_size ([1024, 768])
49
54
  # The dimensions of the browser window in which to test, expressed as a
50
55
  # 2-element array, e.g. `[1024, 768]`.
@@ -124,28 +129,9 @@ module Ferrum
124
129
  @options = Options.new(options)
125
130
  @client = @process = @contexts = nil
126
131
 
127
- @timeout = @options.timeout
128
- @window_size = @options.window_size
129
- @base_url = @options.base_url if @options.base_url
130
-
131
132
  start
132
133
  end
133
134
 
134
- #
135
- # Sets the base URL.
136
- #
137
- # @param [String] value
138
- # The new base URL value.
139
- #
140
- # @raise [ArgumentError] when path is not absolute or doesn't include schema
141
- #
142
- # @return [Addressable::URI]
143
- # The parsed base URI value.
144
- #
145
- def base_url=(value)
146
- @base_url = options.parse_base_url(value)
147
- end
148
-
149
135
  #
150
136
  # Creates a new page.
151
137
  #
@@ -163,7 +149,7 @@ module Ferrum
163
149
  params = {}
164
150
 
165
151
  if proxy
166
- options.parse_proxy(proxy)
152
+ options.validate_proxy(proxy)
167
153
  params.merge!(proxyServer: "#{proxy[:host]}:#{proxy[:port]}")
168
154
  params.merge!(proxyBypassList: proxy[:bypass]) if proxy[:bypass]
169
155
  end
@@ -182,12 +168,6 @@ module Ferrum
182
168
  end
183
169
  end
184
170
 
185
- def extensions
186
- @extensions ||= Array(options.extensions).map do |ext|
187
- (ext.is_a?(Hash) && ext[:source]) || File.read(ext)
188
- end
189
- end
190
-
191
171
  #
192
172
  # Evaluate JavaScript to modify things before a page load.
193
173
  #
@@ -205,13 +185,6 @@ module Ferrum
205
185
  extensions << expression
206
186
  end
207
187
 
208
- def command(*args)
209
- @client.command(*args)
210
- rescue DeadBrowserError
211
- restart
212
- raise
213
- end
214
-
215
188
  #
216
189
  # Closes browser tabs opened by the `Browser` instance.
217
190
  #
@@ -227,7 +200,6 @@ module Ferrum
227
200
  # browser.quit
228
201
  #
229
202
  def reset
230
- @window_size = options.window_size
231
203
  contexts.reset
232
204
  end
233
205
 
@@ -239,20 +211,29 @@ module Ferrum
239
211
  def quit
240
212
  return unless @client
241
213
 
214
+ contexts.close_connections
215
+
242
216
  @client.close
243
217
  @process.stop
244
218
  @client = @process = @contexts = nil
245
219
  end
246
220
 
247
- def resize(**options)
248
- @window_size = [options[:width], options[:height]]
249
- page.resize(**options)
250
- end
251
-
221
+ #
222
+ # Crashes browser.
223
+ #
252
224
  def crash
253
225
  command("Browser.crash")
254
226
  end
255
227
 
228
+ #
229
+ # Close browser gracefully.
230
+ #
231
+ # You should clean up resources/connections in ruby world manually, it's only a CDP command.
232
+ #
233
+ def close
234
+ command("Browser.close")
235
+ end
236
+
256
237
  #
257
238
  # Gets the version information from the browser.
258
239
  #
@@ -272,11 +253,18 @@ module Ferrum
272
253
 
273
254
  def start
274
255
  Utils::ElapsedTime.start
275
- @process = Process.start(options)
276
- @client = Client.new(@process.ws_url, self,
277
- logger: options.logger,
278
- ws_max_receive_size: options.ws_max_receive_size)
279
- @contexts = Contexts.new(self)
256
+ @process = Process.new(options)
257
+
258
+ begin
259
+ @process.start
260
+ @options.default_user_agent = @process.default_user_agent
261
+
262
+ @client = Client.new(@process.ws_url, options)
263
+ @contexts = Contexts.new(@client)
264
+ rescue StandardError
265
+ @process.stop
266
+ raise
267
+ end
280
268
  end
281
269
  end
282
270
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Client
5
+ class Subscriber
6
+ INTERRUPTIONS = %w[Fetch.requestPaused Fetch.authRequired].freeze
7
+
8
+ def initialize
9
+ @regular = Queue.new
10
+ @priority = Queue.new
11
+ @on = Concurrent::Hash.new { |h, k| h[k] = Concurrent::Array.new }
12
+
13
+ start
14
+ end
15
+
16
+ def <<(message)
17
+ if INTERRUPTIONS.include?(message["method"])
18
+ @priority.push(message)
19
+ else
20
+ @regular.push(message)
21
+ end
22
+ end
23
+
24
+ def on(event, &block)
25
+ @on[event] << block
26
+ true
27
+ end
28
+
29
+ def subscribed?(event)
30
+ @on.key?(event)
31
+ end
32
+
33
+ def close
34
+ @regular_thread&.kill
35
+ @priority_thread&.kill
36
+ end
37
+
38
+ def clear(session_id:)
39
+ @on.delete_if { |k, _| k.match?(session_id) }
40
+ end
41
+
42
+ private
43
+
44
+ def start
45
+ @regular_thread = Utils::Thread.spawn(abort_on_exception: false) do
46
+ loop do
47
+ message = @regular.pop
48
+ break unless message
49
+
50
+ call(message)
51
+ end
52
+ end
53
+
54
+ @priority_thread = Utils::Thread.spawn(abort_on_exception: false) do
55
+ loop do
56
+ message = @priority.pop
57
+ break unless message
58
+
59
+ call(message)
60
+ end
61
+ end
62
+ end
63
+
64
+ def call(message)
65
+ method, session_id, params = message.values_at("method", "sessionId", "params")
66
+ event = SessionClient.event_name(method, session_id)
67
+
68
+ total = @on[event].size
69
+ @on[event].each_with_index do |block, index|
70
+ # In case of multiple callbacks we provide current index and total
71
+ block.call(params, index, total)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "websocket/driver"
6
+
7
+ module Ferrum
8
+ class Client
9
+ class WebSocket
10
+ WEBSOCKET_BUG_SLEEP = 0.05
11
+ DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze
12
+ SKIP_LOGGING_SCREENSHOTS = !ENV["FERRUM_LOGGING_SCREENSHOTS"]
13
+
14
+ attr_reader :url, :messages
15
+
16
+ def initialize(url, max_receive_size, logger)
17
+ @url = url
18
+ @logger = logger
19
+ uri = URI.parse(@url)
20
+ port = uri.port || DEFAULT_PORTS[uri.scheme]
21
+
22
+ if port == 443
23
+ tcp = TCPSocket.new(uri.host, port)
24
+ ssl_context = OpenSSL::SSL::SSLContext.new
25
+ @sock = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
26
+ @sock.sync_close = true
27
+ @sock.connect
28
+ else
29
+ @sock = TCPSocket.new(uri.host, port)
30
+ end
31
+
32
+ max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
33
+ @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
34
+ @messages = Queue.new
35
+
36
+ @screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
37
+
38
+ @driver.on(:open, &method(:on_open))
39
+ @driver.on(:message, &method(:on_message))
40
+ @driver.on(:close, &method(:on_close))
41
+
42
+ start
43
+
44
+ @driver.start
45
+ end
46
+
47
+ def on_open(_event)
48
+ # https://github.com/faye/websocket-driver-ruby/issues/46
49
+ sleep(WEBSOCKET_BUG_SLEEP)
50
+ end
51
+
52
+ def on_message(event)
53
+ data = safely_parse_json(event.data)
54
+ # If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
55
+ # don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
56
+ # anything or skip the background notification, but at least we don't crash the thread that crashes the main
57
+ # thread and the application.
58
+ @messages.push(data) if data
59
+
60
+ output = event.data
61
+ if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data&.dig("id")]
62
+ @screenshot_commands.delete(data&.dig("id"))
63
+ output.sub!(/{"data":"[^"]*"}/, %("Set FERRUM_LOGGING_SCREENSHOTS=true to see screenshots in Base64"))
64
+ end
65
+
66
+ @logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
67
+ end
68
+
69
+ def on_close(_event)
70
+ @messages.close
71
+ @sock.close
72
+ @thread.kill
73
+ end
74
+
75
+ def send_message(data)
76
+ @screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
77
+
78
+ json = data.to_json
79
+ @driver.text(json)
80
+ @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
81
+ end
82
+
83
+ def write(data)
84
+ @sock.write(data)
85
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
86
+ @messages.close
87
+ end
88
+
89
+ def close
90
+ @driver.close
91
+ end
92
+
93
+ private
94
+
95
+ def start
96
+ @thread = Utils::Thread.spawn do
97
+ loop do
98
+ data = @sock.readpartial(512)
99
+ break unless data
100
+
101
+ @driver.parse(data)
102
+ end
103
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
104
+ @messages.close
105
+ end
106
+ end
107
+
108
+ def safely_parse_json(data)
109
+ JSON.parse(data, max_nesting: false)
110
+ rescue JSON::NestingError
111
+ # nop
112
+ rescue JSON::ParserError
113
+ safely_parse_escaped_json(data)
114
+ end
115
+
116
+ def safely_parse_escaped_json(data)
117
+ unescaped_unicode =
118
+ data.gsub(/\\u([\da-fA-F]{4})/) { |_| [::Regexp.last_match(1)].pack("H*").unpack("n*").pack("U*") }
119
+ escaped_data = unescaped_unicode.encode("UTF-8", "UTF-8", undef: :replace, invalid: :replace, replace: "?")
120
+ JSON.parse(escaped_data, max_nesting: false)
121
+ rescue JSON::ParserError
122
+ # nop
123
+ end
124
+ end
125
+ end
126
+ end