http_mimic 0.3.1 → 0.4.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.
@@ -47,6 +47,44 @@ module HttpMimic
47
47
  @store.values
48
48
  end
49
49
 
50
+ def empty?
51
+ @store.empty?
52
+ end
53
+
54
+ def size
55
+ @store.size
56
+ end
57
+ alias length size
58
+
59
+ def delete(name)
60
+ @cookie_items.delete(name.to_s)
61
+ @store.delete(name.to_s)
62
+ end
63
+
64
+ def clear
65
+ @cookie_items.clear
66
+ @store.clear
67
+ end
68
+
69
+ def merge(other)
70
+ dup_cookies = self.class.new(@store)
71
+ if other.is_a?(Cookies)
72
+ other.each { |k, v| dup_cookies[k] = v }
73
+ elsif other.is_a?(Hash)
74
+ other.each { |k, v| dup_cookies[k] = v }
75
+ end
76
+ dup_cookies
77
+ end
78
+
79
+ def merge!(other)
80
+ if other.is_a?(Cookies)
81
+ other.each { |k, v| self[k] = v }
82
+ elsif other.is_a?(Hash)
83
+ other.each { |k, v| self[k] = v }
84
+ end
85
+ self
86
+ end
87
+
50
88
  def to_h
51
89
  @store.dup
52
90
  end
@@ -13,6 +13,9 @@ module HttpMimic
13
13
  DEFAULT_GITHUB_REPO = 'lexiforest/curl-impersonate'
14
14
  DEFAULT_VERSION = 'v2.1.1'
15
15
 
16
+ DEFAULT_QJS_REPO = 'quickjs-ng/quickjs'
17
+ DEFAULT_QJS_VERSION = 'v0.16.2'
18
+
16
19
  class DownloadError < HttpMimic::Error; end
17
20
  class UnsupportedPlatformError < HttpMimic::Error; end
18
21
 
@@ -63,6 +66,105 @@ module HttpMimic
63
66
  true
64
67
  end
65
68
 
69
+ def download_qjs!(version: nil, install_dir: nil, repo: nil, force: false)
70
+ target_version = normalize_version(version || HttpMimic.configuration.qjs_version || DEFAULT_QJS_VERSION)
71
+ target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
72
+ target_repo = repo || HttpMimic.configuration.qjs_github_repo || DEFAULT_QJS_REPO
73
+
74
+ FileUtils.mkdir_p(target_dir)
75
+
76
+ dest_binary = File.join(target_dir, binary_name_for_platform('qjs'))
77
+
78
+ if !force && qjs_installed?(version: target_version, install_dir: target_dir)
79
+ log_info("qjs #{target_version} is already installed in #{target_dir}")
80
+ return dest_binary
81
+ end
82
+
83
+ asset_name = qjs_platform_asset
84
+ download_url = "https://github.com/#{target_repo}/releases/download/#{target_version}/#{asset_name}"
85
+
86
+ log_info("Downloading QuickJS (#{target_version}) [#{asset_name}]...")
87
+ binary_data = fetch_binary(download_url)
88
+
89
+ File.binwrite(dest_binary, binary_data)
90
+ File.chmod(0755, dest_binary)
91
+
92
+ File.write(qjs_version_file_path(target_dir), target_version)
93
+
94
+ log_info("QuickJS #{target_version} installation complete! (#{dest_binary})")
95
+ dest_binary
96
+ end
97
+
98
+ def qjs_installed?(version: nil, install_dir: nil)
99
+ target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
100
+ qjs_bin = File.join(target_dir, binary_name_for_platform('qjs'))
101
+ return false unless File.file?(qjs_bin) && File.executable?(qjs_bin)
102
+
103
+ if version
104
+ target_version = normalize_version(version)
105
+ v_file = qjs_version_file_path(target_dir)
106
+ return false unless File.file?(v_file)
107
+ return File.read(v_file).strip == target_version
108
+ end
109
+
110
+ true
111
+ end
112
+
113
+ def qjs_path(install_dir: nil)
114
+ target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
115
+ candidate = File.join(target_dir, binary_name_for_platform('qjs'))
116
+ return candidate if File.file?(candidate) && File.executable?(candidate)
117
+
118
+ # Fallback to system PATH
119
+ sys_qjs = `which qjs 2>/dev/null`.strip
120
+ return sys_qjs if !sys_qjs.empty? && File.executable?(sys_qjs)
121
+
122
+ nil
123
+ end
124
+
125
+ def qjs_platform_asset
126
+ os = host_os
127
+ cpu = host_cpu
128
+
129
+ case os
130
+ when :macos
131
+ case cpu
132
+ when :arm64 then 'qjs-darwin-arm64'
133
+ when :x86_64 then 'qjs-darwin-x86_64'
134
+ else
135
+ raise UnsupportedPlatformError, "Unsupported macOS CPU architecture for QuickJS: #{cpu}"
136
+ end
137
+ when :linux
138
+ case cpu
139
+ when :x86_64
140
+ 'qjs-linux-x86_64'
141
+ when :aarch64, :arm64
142
+ 'qjs-linux-aarch64'
143
+ when :arm
144
+ 'qjs-linux-armv7'
145
+ when :i386, :i686
146
+ 'qjs-linux-x86'
147
+ when :riscv64
148
+ 'qjs-linux-riscv64'
149
+ else
150
+ raise UnsupportedPlatformError, "Unsupported Linux CPU architecture for QuickJS: #{cpu}"
151
+ end
152
+ when :windows
153
+ case cpu
154
+ when :x86_64 then 'qjs-windows-x86_64.exe'
155
+ when :i386, :i686 then 'qjs-windows-x86.exe'
156
+ else
157
+ raise UnsupportedPlatformError, "Unsupported Windows CPU architecture for QuickJS: #{cpu}"
158
+ end
159
+ else
160
+ raise UnsupportedPlatformError, "Unsupported operating system for QuickJS: #{RbConfig::CONFIG['host_os']}"
161
+ end
162
+ end
163
+
164
+ def qjs_version_file_path(dir)
165
+ File.join(dir, '.qjs_version')
166
+ end
167
+
66
168
  def binary_path(name, install_dir: nil)
67
169
  target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
68
170
  candidate = File.join(target_dir, binary_name_for_platform(name))
@@ -31,4 +31,19 @@ module HttpMimic
31
31
 
32
32
  # Raised when response parsing fails
33
33
  class ResponseParseError < Error; end
34
+
35
+ # Raised when JavaScript execution fails in JSRuntime
36
+ class JSError < Error
37
+ attr_reader :stderr, :exit_code
38
+
39
+ def initialize(message, stderr: nil, exit_code: nil)
40
+ super(message)
41
+ @stderr = stderr
42
+ @exit_code = exit_code
43
+ end
44
+ end
45
+
46
+ # Raised when JavaScript execution times out
47
+ class JSTimeoutError < JSError; end
34
48
  end
49
+
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+ require 'timeout'
6
+
7
+ module HttpMimic
8
+ class JSRuntime
9
+ STDIN_RUNNER = <<~JS
10
+ const input = std.in.readAsString();
11
+ const result = eval(input);
12
+ if (result !== undefined) {
13
+ if (typeof result === "object" && result !== null) {
14
+ console.log(JSON.stringify(result));
15
+ } else {
16
+ console.log(result);
17
+ }
18
+ }
19
+ JS
20
+
21
+ class << self
22
+ def eval(js_code, timeout: nil, install_dir: nil, auto_download: nil)
23
+ ensure_qjs_installed!(install_dir: install_dir, auto_download: auto_download)
24
+
25
+ qjs_bin = Downloader.qjs_path(install_dir: install_dir)
26
+ raise BinaryNotFoundError, "QuickJS binary 'qjs' not found. Run HttpMimic.download_qjs! to install." unless qjs_bin
27
+
28
+ cmd = [qjs_bin, '--std', '-e', STDIN_RUNNER]
29
+ effective_timeout = timeout || HttpMimic.configuration.default_timeout
30
+
31
+ stdout = nil
32
+ stderr = nil
33
+ status = nil
34
+
35
+ begin
36
+ if effective_timeout && effective_timeout > 0
37
+ Timeout.timeout(effective_timeout) do
38
+ stdout, stderr, status = Open3.capture3(*cmd, stdin_data: js_code.to_s)
39
+ end
40
+ else
41
+ stdout, stderr, status = Open3.capture3(*cmd, stdin_data: js_code.to_s)
42
+ end
43
+ rescue Timeout::Error
44
+ raise JSTimeoutError.new("JavaScript execution timed out after #{effective_timeout}s")
45
+ end
46
+
47
+ unless status && status.success?
48
+ raise JSError.new(
49
+ "JavaScript execution failed: #{stderr.to_s.strip}",
50
+ stderr: stderr,
51
+ exit_code: status ? status.exitstatus : nil
52
+ )
53
+ end
54
+
55
+ stdout.to_s.strip
56
+ end
57
+
58
+ def eval_json(js_code, timeout: nil, install_dir: nil, auto_download: nil)
59
+ output = eval(js_code, timeout: timeout, install_dir: install_dir, auto_download: auto_download)
60
+ return nil if output.nil? || output.empty?
61
+
62
+ JSON.parse(output)
63
+ rescue JSON::ParserError => e
64
+ raise ResponseParseError, "Failed to parse JavaScript JSON output: #{e.message} (Output: #{output.inspect})"
65
+ end
66
+
67
+ private
68
+
69
+ def ensure_qjs_installed!(install_dir: nil, auto_download: nil)
70
+ should_download = auto_download.nil? ? HttpMimic.configuration.auto_download : auto_download
71
+ return unless should_download
72
+ return if Downloader.qjs_installed?(install_dir: install_dir)
73
+
74
+ Downloader.download_qjs!(install_dir: install_dir)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -41,6 +41,11 @@ module HttpMimic
41
41
  default_options[:cookies].merge!(c)
42
42
  end
43
43
 
44
+ def persist_cookies(enabled = nil)
45
+ return default_options[:persist_cookies] if enabled.nil?
46
+ default_options[:persist_cookies] = enabled
47
+ end
48
+
44
49
  def mode(m = nil)
45
50
  return default_options[:mode] if m.nil?
46
51
  default_options[:mode] = m
@@ -18,8 +18,22 @@ module HttpMimic
18
18
  auto_fallback = options.fetch(:auto_fallback, config.auto_fallback)
19
19
  retry_statuses = options[:retry_statuses] || config.retry_statuses || [403, 429, 503]
20
20
 
21
+ # Persistent CookieStore integration
22
+ should_persist_cookie = options.fetch(:persist_cookies, config.persist_cookies)
23
+ host = CookieStore.extract_host(url)
24
+
25
+ if should_persist_cookie && host
26
+ stored_cookies = CookieStore.load(host)
27
+ if stored_cookies && !stored_cookies.empty?
28
+ log_debug("[HttpMimic::CookieStore] Loaded #{stored_cookies.size} persistent cookies for #{host}")
29
+ user_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
30
+ options[:cookies] = stored_cookies.merge(user_cookies)
31
+ end
32
+ end
33
+
21
34
  attempts = []
22
35
  profiles_to_try = determine_profiles(mode, auto_fallback)
36
+ waf_solve_attempted = false
23
37
 
24
38
  response = nil
25
39
  final_status = nil
@@ -66,7 +80,32 @@ module HttpMimic
66
80
  final_stderr = stderr
67
81
  final_command = full_command
68
82
 
69
- is_blocked = (status.exitstatus != 0) || retry_statuses.include?(response.code)
83
+ # Forward any received cookies to subsequent attempts
84
+ if response.cookies && !response.cookies.empty?
85
+ existing_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
86
+ options[:cookies] = response.cookies.to_h.merge(existing_cookies)
87
+ end
88
+
89
+ auto_solve_waf = options.fetch(:solve_waf, config.auto_solve_waf)
90
+ is_blocked = (status.exitstatus != 0) || retry_statuses.include?(response.code) || Waf::Detector.challenge_page?(response)
91
+
92
+ # Only attempt WAF resolution once per request to avoid unnecessary latency on subsequent fallbacks
93
+ if is_blocked && auto_solve_waf && !waf_solve_attempted && method.to_s.upcase == 'GET'
94
+ waf_type = Waf::Detector.detect(response)
95
+ if waf_type
96
+ waf_solve_attempted = true
97
+ log_debug("[HttpMimic] Detected #{waf_type.to_s.capitalize} WAF challenge. Attempting to solve with QuickJS...")
98
+ solved_resp = Waf.solve(url, response, current_opts)
99
+ if solved_resp
100
+ response = solved_resp
101
+ is_blocked = (response.code != 0 && retry_statuses.include?(response.code))
102
+ if response.cookies && !response.cookies.empty?
103
+ options[:cookies] = (options[:cookies] || {}).merge(response.cookies.to_h)
104
+ end
105
+ end
106
+ end
107
+ end
108
+
70
109
  if !is_blocked || (index == profiles_to_try.size - 1)
71
110
  break
72
111
  end
@@ -74,6 +113,12 @@ module HttpMimic
74
113
  log_debug("[HttpMimic] Attempt #{index + 1} with #{profile} resulted in status #{response.code}. Triggering smart fallback to next profile...")
75
114
  end
76
115
 
116
+ # Persist cookies back to store if enabled
117
+ if should_persist_cookie && host && response && response.cookies && !response.cookies.empty?
118
+ CookieStore.save(host, response.cookies)
119
+ log_debug("[HttpMimic::CookieStore] Saved #{response.cookies.size} cookies for #{host}")
120
+ end
121
+
77
122
  handle_errors(final_status, final_stderr, final_command, response)
78
123
  response
79
124
  end
@@ -83,17 +128,29 @@ module HttpMimic
83
128
  def determine_profiles(mode, auto_fallback)
84
129
  case mode
85
130
  when :curl, :curl_first
86
- auto_fallback ? [:curl, :impersonate] : [:curl]
131
+ auto_fallback ? [:curl, :impersonate, :android, :ios] : [:curl]
87
132
  when :curl_only
88
133
  [:curl]
89
134
  when :impersonate_only
90
135
  [:impersonate]
136
+ when :mobile_only
137
+ [:mobile]
138
+ when :android_only
139
+ [:android]
140
+ when :ios_only
141
+ [:ios]
142
+ when :mobile_first
143
+ auto_fallback ? [:android, :ios, :impersonate, :curl] : [:mobile]
144
+ when :android_first
145
+ auto_fallback ? [:android, :ios, :impersonate, :curl] : [:android]
146
+ when :ios_first
147
+ auto_fallback ? [:ios, :android, :impersonate, :curl] : [:ios]
91
148
  when :impersonate_first
92
- auto_fallback ? [:impersonate, :curl] : [:impersonate]
149
+ auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
93
150
  when :auto, :smart
94
- auto_fallback ? [:impersonate, :curl] : [:impersonate]
151
+ auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
95
152
  else
96
- auto_fallback ? [:impersonate, :curl] : [:impersonate]
153
+ auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
97
154
  end
98
155
  end
99
156
 
@@ -67,6 +67,76 @@ module HttpMimic
67
67
  client_error? || server_error?
68
68
  end
69
69
 
70
+ def binary?
71
+ content_type = headers['content-type'].to_s.downcase
72
+ ResponseParser::BINARY_MIME_KEYWORDS.any? { |kw| content_type.include?(kw) } ||
73
+ body.to_s.b[0, 1024]&.include?("\x00".b)
74
+ end
75
+
76
+ def save_to_file(filepath)
77
+ require 'fileutils'
78
+ FileUtils.mkdir_p(File.dirname(filepath))
79
+ File.binwrite(filepath, body)
80
+ filepath
81
+ end
82
+ alias save save_to_file
83
+
84
+ def title
85
+ return nil if binary?
86
+ body[/<title[^>]*>(.*?)<\/title>/im, 1]&.strip
87
+ end
88
+
89
+ def og_image
90
+ return nil if binary?
91
+ body[/<meta\s+[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/im, 1] ||
92
+ body[/<meta\s+[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/im, 1]
93
+ end
94
+
95
+ def meta_description
96
+ return nil if binary?
97
+ body[/<meta\s+[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/im, 1] ||
98
+ body[/<meta\s+[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/im, 1]
99
+ end
100
+
101
+ def extract_images(base_url: nil)
102
+ return [] if binary?
103
+
104
+ base = base_url || request_url || ''
105
+ images = []
106
+
107
+ # 1. Match img tags (src, data-src, data-zoom-image, data-original, srcset)
108
+ body.scan(/<img\s+[^>]*>/i).each do |img_tag|
109
+ %w[src data-src data-zoom-image data-original data-high-res-src data-full-size-image-url].each do |attr|
110
+ if match = img_tag.match(/#{attr}=["']([^"']+)["']/i)
111
+ images << match[1].strip
112
+ end
113
+ end
114
+
115
+ if match = img_tag.match(/srcset=["']([^"']+)["']/i)
116
+ match[1].split(',').each do |item|
117
+ url = item.strip.split(/\s+/).first
118
+ images << url if url && !url.empty?
119
+ end
120
+ end
121
+ end
122
+
123
+ # 2. Match og:image meta tag
124
+ if og = og_image
125
+ images << og
126
+ end
127
+
128
+ # 3. Match raw image URLs found in document / script payloads
129
+ body.scan(/https?:[^\s"'<>]+\.(?:jpg|jpeg|png|webp|avif|gif)/i).each do |raw_url|
130
+ images << raw_url
131
+ end
132
+
133
+ # Clean, resolve relative URLs to absolute, and deduplicate
134
+ images.map do |img|
135
+ clean_url = img.gsub(/&amp;/, '&').strip
136
+ resolve_url(clean_url, base)
137
+ end.compact.reject(&:empty?).uniq
138
+ end
139
+
70
140
  def [](key)
71
141
  if parsed_response.is_a?(Hash) || parsed_response.is_a?(Array)
72
142
  parsed_response[key]
@@ -92,6 +162,20 @@ module HttpMimic
92
162
  "#<#{self.class.name}:0x#{object_id.to_s(16)} @code=#{code} @status_message=#{status_message.inspect} @headers=#{headers.to_h.inspect} @parsed_response=#{parsed_response.inspect}>"
93
163
  end
94
164
 
165
+ private
166
+
167
+ def resolve_url(url, base)
168
+ return nil if url.nil? || url.empty? || url.start_with?('data:', 'javascript:', 'blob:', '#')
169
+ return url if url =~ /\Ahttps?:\/\//i
170
+ return "https:#{url}" if url.start_with?('//')
171
+
172
+ return url if base.nil? || base.empty?
173
+ require 'uri'
174
+ URI.join(base, url).to_s
175
+ rescue URI::InvalidURIError
176
+ url
177
+ end
178
+
95
179
  def method_missing(method_name, *args, &block)
96
180
  if parsed_response.respond_to?(method_name)
97
181
  parsed_response.public_send(method_name, *args, &block)
@@ -5,6 +5,8 @@ require 'json'
5
5
  module HttpMimic
6
6
  class ResponseParser
7
7
  HTTP_STATUS_LINE_REGEX = /\AHTTP\/(?<version>[\d\.]+)\s+(?<code>\d{3})(?:\s+(?<message>.*))?/i
8
+ HTTP_STATUS_LINE_B = /\AHTTP\/(?:[\d\.]+)\s+\d{3}/i
9
+ BINARY_MIME_KEYWORDS = %w[image/ audio/ video/ pdf octet-stream zip gzip tar compressed stream font wasm].freeze
8
10
 
9
11
  attr_reader :raw_output, :exit_status, :stderr, :command, :request_url
10
12
 
@@ -17,8 +19,8 @@ module HttpMimic
17
19
  end
18
20
 
19
21
  def parse
20
- # Split header blocks and body
21
- header_blocks, body = split_headers_and_body(@raw_output)
22
+ # Split header blocks and body in binary-safe manner
23
+ header_blocks, raw_body = split_headers_and_body(@raw_output)
22
24
 
23
25
  final_header_block = header_blocks.last || ''
24
26
  history_header_blocks = header_blocks.size > 1 ? header_blocks[0...-1] : []
@@ -39,6 +41,14 @@ module HttpMimic
39
41
  }
40
42
  end
41
43
 
44
+ # Process body: retain raw bytes for binary, or UTF-8 encode for text
45
+ content_type = headers['content-type'].to_s.downcase
46
+ body = if binary_content?(content_type, raw_body)
47
+ raw_body
48
+ else
49
+ raw_body.dup.force_encoding('UTF-8').scrub
50
+ end
51
+
42
52
  # Parse body (auto-detect JSON)
43
53
  parsed_body = parse_body(body, headers)
44
54
 
@@ -61,32 +71,36 @@ module HttpMimic
61
71
 
62
72
  private
63
73
 
64
- # Split raw_output into header blocks and body by \r?\n\r?\n
74
+ # Split raw_output into header blocks and body in a binary-safe manner
65
75
  def split_headers_and_body(text)
66
- return [[], ''] if text.nil? || text.empty?
67
-
68
- # Normalize line endings
69
- normalized = text.gsub(/\r\n/, "\n")
70
- parts = normalized.split("\n\n")
76
+ return [[], ''.b] if text.nil? || text.empty?
71
77
 
78
+ remaining = text.to_s.b
72
79
  header_blocks = []
73
- body_index = 0
74
80
 
75
- parts.each_with_index do |part, idx|
76
- trimmed = part.strip
77
- if trimmed =~ HTTP_STATUS_LINE_REGEX
78
- header_blocks << part
79
- body_index = idx + 1
81
+ while remaining =~ HTTP_STATUS_LINE_B
82
+ crlf_idx = remaining.index("\r\n\r\n".b)
83
+ lf_idx = remaining.index("\n\n".b)
84
+
85
+ break unless crlf_idx || lf_idx
86
+
87
+ if crlf_idx && lf_idx
88
+ delim_pos = [crlf_idx, lf_idx].min
89
+ delim_len = (delim_pos == crlf_idx) ? 4 : 2
90
+ elsif crlf_idx
91
+ delim_pos = crlf_idx
92
+ delim_len = 4
80
93
  else
81
- # Once a non-HTTP status block is encountered, the rest is body
82
- break
94
+ delim_pos = lf_idx
95
+ delim_len = 2
83
96
  end
84
- end
85
97
 
86
- # Combine remaining parts as body
87
- body = parts[body_index..-1] ? parts[body_index..-1].join("\n\n") : ''
98
+ header_block = remaining[0...delim_pos].force_encoding('UTF-8').scrub
99
+ header_blocks << header_block
100
+ remaining = remaining[(delim_pos + delim_len)..-1] || ''.b
101
+ end
88
102
 
89
- [header_blocks, body]
103
+ [header_blocks, remaining]
90
104
  end
91
105
 
92
106
  def parse_header_block(block)
@@ -129,12 +143,22 @@ module HttpMimic
129
143
  [code, http_version, status_message, headers, cookies]
130
144
  end
131
145
 
146
+ def binary_content?(content_type, body)
147
+ return true if BINARY_MIME_KEYWORDS.any? { |kw| content_type.include?(kw) }
148
+
149
+ # Check for null bytes in the initial portion of body
150
+ sample = body[0, 1024]
151
+ sample&.include?("\x00".b)
152
+ end
153
+
132
154
  def parse_body(body, headers)
133
155
  return nil if body.nil? || body.empty?
134
156
 
135
157
  content_type = headers['content-type'].to_s.downcase
158
+ return body if binary_content?(content_type, body)
136
159
 
137
- if content_type.include?('json') || body.strip.start_with?('{', '[')
160
+ trimmed = body.strip
161
+ if content_type.include?('json') || trimmed.start_with?('{', '[')
138
162
  begin
139
163
  JSON.parse(body)
140
164
  rescue JSON::ParserError
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HttpMimic
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end