http_mimic 0.3.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.
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'json'
5
+
6
+ module HttpMimic
7
+ class CommandBuilder
8
+ COMMON_SEARCH_PATHS = [
9
+ '/opt/homebrew/bin',
10
+ '/usr/local/bin',
11
+ '/usr/bin',
12
+ '/bin',
13
+ File.expand_path('~/.local/bin')
14
+ ].freeze
15
+
16
+ TARGET_BINARY_MAP = {
17
+ 'chrome' => %w[curl_chrome131 curl_chrome124 curl_chrome120 curl_chrome116 curl_chrome110 curl-impersonate-chrome curl_chrome curl-impersonate],
18
+ 'chrome116' => %w[curl_chrome116 curl-impersonate-chrome curl_chrome curl-impersonate],
19
+ 'chrome120' => %w[curl_chrome120 curl-impersonate-chrome curl_chrome curl-impersonate],
20
+ 'chrome123' => %w[curl_chrome123 curl-impersonate-chrome curl_chrome curl-impersonate],
21
+ 'chrome124' => %w[curl_chrome124 curl-impersonate-chrome curl_chrome curl-impersonate],
22
+ 'chrome131' => %w[curl_chrome131 curl-impersonate-chrome curl_chrome curl-impersonate],
23
+ 'chrome133a' => %w[curl_chrome133a curl-impersonate-chrome curl_chrome curl-impersonate],
24
+ 'chrome136' => %w[curl_chrome136 curl-impersonate-chrome curl_chrome curl-impersonate],
25
+ 'chrome142' => %w[curl_chrome142 curl-impersonate-chrome curl_chrome curl-impersonate],
26
+ 'chrome110' => %w[curl_chrome110 curl-impersonate-chrome curl_chrome curl-impersonate],
27
+ 'chrome107' => %w[curl_chrome107 curl-impersonate-chrome curl_chrome curl-impersonate],
28
+ 'chrome104' => %w[curl_chrome104 curl-impersonate-chrome curl_chrome curl-impersonate],
29
+ 'chrome101' => %w[curl_chrome101 curl-impersonate-chrome curl_chrome curl-impersonate],
30
+ 'chrome100' => %w[curl_chrome100 curl-impersonate-chrome curl_chrome curl-impersonate],
31
+ 'chrome99' => %w[curl_chrome99 curl-impersonate-chrome curl_chrome curl-impersonate],
32
+ 'firefox' => %w[curl_firefox135 curl_firefox133 curl_ff117 curl_firefox117 curl_ff109 curl-impersonate-ff curl_firefox curl-impersonate],
33
+ 'firefox135' => %w[curl_firefox135 curl-impersonate-ff curl_firefox curl-impersonate],
34
+ 'firefox133' => %w[curl_firefox133 curl-impersonate-ff curl_firefox curl-impersonate],
35
+ 'firefox117' => %w[curl_ff117 curl_firefox117 curl-impersonate-ff curl_firefox curl-impersonate],
36
+ 'firefox109' => %w[curl_ff109 curl_firefox109 curl-impersonate-ff curl_firefox curl-impersonate],
37
+ 'firefox102' => %w[curl_ff102 curl_firefox102 curl-impersonate-ff curl_firefox curl-impersonate],
38
+ 'firefox98' => %w[curl_ff98 curl_firefox98 curl-impersonate-ff curl_firefox curl-impersonate],
39
+ 'safari' => %w[curl_safari180 curl_safari170 curl_safari155 curl_safari153 curl-impersonate-safari curl_safari curl-impersonate],
40
+ 'safari180' => %w[curl_safari180 curl_safari18_0 curl-impersonate-safari curl_safari curl-impersonate],
41
+ 'safari170' => %w[curl_safari170 curl_safari17_0 curl-impersonate-safari curl_safari curl-impersonate],
42
+ 'safari155' => %w[curl_safari155 curl_safari15_5 curl-impersonate-safari curl_safari curl-impersonate],
43
+ 'safari15_5' => %w[curl_safari155 curl_safari15_5 curl-impersonate-safari curl_safari curl-impersonate],
44
+ 'safari153' => %w[curl_safari153 curl_safari15_3 curl-impersonate-safari curl_safari curl-impersonate],
45
+ 'safari15_3' => %w[curl_safari153 curl_safari15_3 curl-impersonate-safari curl_safari curl-impersonate],
46
+ 'edge' => %w[curl_edge101 curl_edge99 curl-impersonate-edge curl_edge curl-impersonate],
47
+ 'edge101' => %w[curl_edge101 curl-impersonate-edge curl_edge curl-impersonate],
48
+ 'edge99' => %w[curl_edge99 curl-impersonate-edge curl_edge curl-impersonate],
49
+ 'tor' => %w[curl_tor145 curl_tor curl-impersonate],
50
+ 'tor145' => %w[curl_tor145 curl_tor curl-impersonate]
51
+ }.freeze
52
+
53
+ attr_reader :method, :url, :options, :config
54
+
55
+ def initialize(method, url, options = {}, config = nil)
56
+ @method = method.to_s.upcase
57
+ @url = url.to_s
58
+ @options = options.dup
59
+ @config = config || HttpMimic.configuration
60
+ end
61
+
62
+ def build
63
+ binary = resolve_binary
64
+ args = []
65
+ stdin_data = nil
66
+
67
+ # Base flags: silent mode, include HTTP headers
68
+ args << '-s'
69
+ args << '-i'
70
+
71
+ # HTTP Method
72
+ if @method == 'HEAD'
73
+ args << '-I'
74
+ else
75
+ args << '-X' << @method
76
+ end
77
+
78
+ # Process URL and query parameters
79
+ final_url = build_url
80
+
81
+ # Redirects
82
+ follow = options.fetch(:follow_redirects, config.follow_redirects)
83
+ if follow
84
+ args << '-L'
85
+ max_redirs = options[:max_redirects] || config.max_redirects
86
+ args << '--max-redirs' << max_redirs.to_s if max_redirs
87
+ end
88
+
89
+ # Timeouts
90
+ timeout = options[:timeout] || options[:read_timeout] || config.default_timeout
91
+ args << '--max-time' << timeout.to_s if timeout
92
+
93
+ connect_timeout = options[:connect_timeout] || config.default_connect_timeout
94
+ args << '--connect-timeout' << connect_timeout.to_s if connect_timeout
95
+
96
+ # SSL / Insecure
97
+ insecure = options[:insecure] || (options.key?(:verify_ssl) && !options[:verify_ssl])
98
+ args << '-k' if insecure
99
+ args << '--cacert' << options[:ssl_ca_file] if options[:ssl_ca_file]
100
+ args << '--cert' << options[:ssl_cert] if options[:ssl_cert]
101
+ args << '--key' << options[:ssl_key] if options[:ssl_key]
102
+
103
+ # Proxy
104
+ if options[:proxy]
105
+ args << '-x' << options[:proxy].to_s
106
+ if options[:proxy_auth]
107
+ u = options[:proxy_auth][:username]
108
+ p = options[:proxy_auth][:password]
109
+ args << '--proxy-user' << "#{u}:#{p}"
110
+ end
111
+ end
112
+
113
+ # Basic / Digest Auth
114
+ if options[:basic_auth]
115
+ u = options[:basic_auth][:username]
116
+ p = options[:basic_auth][:password]
117
+ args << '-u' << "#{u}:#{p}"
118
+ elsif options[:digest_auth]
119
+ u = options[:digest_auth][:username]
120
+ p = options[:digest_auth][:password]
121
+ args << '--digest' << '-u' << "#{u}:#{p}"
122
+ end
123
+
124
+ # Headers
125
+ headers_to_send = Headers.new(options[:headers] || {})
126
+
127
+ # Bearer Token
128
+ if options[:bearer_token]
129
+ headers_to_send['Authorization'] = "Bearer #{options[:bearer_token]}"
130
+ end
131
+
132
+ # Body / JSON / Form
133
+ if options.key?(:json)
134
+ headers_to_send['Content-Type'] ||= 'application/json'
135
+ headers_to_send['Accept'] ||= 'application/json'
136
+
137
+ json_data = options[:json]
138
+ stdin_data = json_data.is_a?(String) ? json_data : JSON.dump(json_data)
139
+ args << '-d' << '@-'
140
+ elsif options.key?(:body)
141
+ body_data = options[:body]
142
+ if body_data.is_a?(Hash)
143
+ headers_to_send['Content-Type'] ||= 'application/x-www-form-urlencoded'
144
+ stdin_data = encode_params(body_data)
145
+ else
146
+ stdin_data = body_data.to_s
147
+ end
148
+ args << '-d' << '@-'
149
+ elsif options[:form_data] || options[:form] || options[:multipart]
150
+ form_hash = options[:form_data] || options[:form] || options[:multipart]
151
+ if form_hash.is_a?(Hash)
152
+ form_hash.each do |k, v|
153
+ args << '-F' << "#{k}=#{v}"
154
+ end
155
+ end
156
+ end
157
+
158
+ # Cookies
159
+ if options[:cookies]
160
+ args << '-b' << Cookies.format(options[:cookies])
161
+ elsif options[:cookie_file]
162
+ args << '-b' << options[:cookie_file].to_s
163
+ end
164
+
165
+ if options[:cookie_jar]
166
+ args << '-c' << options[:cookie_jar].to_s
167
+ end
168
+
169
+ # User-Agent
170
+ if options[:user_agent]
171
+ args << '-A' << options[:user_agent].to_s
172
+ end
173
+
174
+ # Append headers
175
+ headers_to_send.each do |k, v|
176
+ if v.is_a?(Array)
177
+ v.each { |item| args << '-H' << "#{k}: #{item}" }
178
+ else
179
+ args << '-H' << "#{k}: #{v}"
180
+ end
181
+ end
182
+
183
+ # Custom extra curl options
184
+ if options[:curl_options]
185
+ extra_opts = options[:curl_options]
186
+ extra_opts = extra_opts.split if extra_opts.is_a?(String)
187
+ args.concat(Array(extra_opts))
188
+ end
189
+
190
+ # Append final URL
191
+ args << final_url
192
+
193
+ [binary, args, stdin_data, final_url]
194
+ end
195
+
196
+ def resolve_binary
197
+ # 1. Use explicit binary_path from options or config if provided
198
+ explicit = options[:binary] || config.binary_path
199
+ if explicit
200
+ path = find_executable(explicit) || explicit
201
+ return path if executable?(path)
202
+ raise BinaryNotFoundError, "Specified executable does not exist or is not executable: #{explicit}"
203
+ end
204
+
205
+ target = (options[:impersonate] || config.default_impersonate).to_s.downcase
206
+ candidate_names = TARGET_BINARY_MAP[target] || ["curl_#{target}", target]
207
+
208
+ # 2. Check local installation directory (~/.http_mimic/bin)
209
+ if binary = find_in_download_dir(candidate_names)
210
+ return binary
211
+ end
212
+
213
+ # 3. Auto-download driver if not installed locally and auto_download is enabled
214
+ if config.auto_download
215
+ begin
216
+ Downloader.download!(version: config.driver_version, install_dir: config.install_dir)
217
+ if binary = find_in_download_dir(candidate_names)
218
+ return binary
219
+ end
220
+ rescue StandardError => e
221
+ if config.logger
222
+ config.logger.warn("[HttpMimic] Failed to auto-download curl-impersonate: #{e.message}")
223
+ elsif config.debug
224
+ puts "[HttpMimic WARN] Failed to auto-download curl-impersonate: #{e.message}"
225
+ end
226
+ end
227
+ end
228
+
229
+ # 4. Search system PATH for target impersonate binary
230
+ candidate_names.each do |name|
231
+ path = find_executable(name)
232
+ return path if path
233
+ end
234
+
235
+ # 5. Fall back to general curl-impersonate binary if specific version is not found
236
+ %w[curl-impersonate-chrome curl-impersonate-ff curl-impersonate].each do |name|
237
+ path = find_executable(name)
238
+ return path if path
239
+ end
240
+
241
+ # 6. Fallback to system curl if enabled
242
+ if config.fallback_to_curl
243
+ curl_path = find_executable('curl') || 'curl'
244
+ return curl_path if executable?(curl_path)
245
+ end
246
+
247
+ raise BinaryNotFoundError, "Could not find a curl-impersonate binary for '#{target}', and no fallback curl executable was found."
248
+ end
249
+
250
+ private
251
+
252
+ def find_in_download_dir(candidates)
253
+ target_dir = File.expand_path(config.install_dir)
254
+ return nil unless Dir.exist?(target_dir)
255
+
256
+ candidates.each do |name|
257
+ path = File.join(target_dir, name)
258
+ return path if executable?(path)
259
+ end
260
+
261
+ # Try generic curl-impersonate binary
262
+ gen_path = File.join(target_dir, 'curl-impersonate')
263
+ return gen_path if executable?(gen_path)
264
+
265
+ nil
266
+ end
267
+
268
+ def build_url
269
+ base = options[:base_uri] || ''
270
+ full = if @url.start_with?('http://', 'https://')
271
+ @url
272
+ elsif base.empty?
273
+ @url
274
+ else
275
+ URI.join(base.end_with?('/') ? base : "#{base}/", @url.sub(%r{^/}, '')).to_s
276
+ end
277
+
278
+ query_params = options[:query] || options[:params]
279
+ if query_params && !query_params.empty?
280
+ uri = URI.parse(full)
281
+ existing_query = uri.query
282
+ new_query = encode_params(query_params)
283
+ uri.query = existing_query && !existing_query.empty? ? "#{existing_query}&#{new_query}" : new_query
284
+ uri.to_s
285
+ else
286
+ full
287
+ end
288
+ rescue URI::InvalidURIError
289
+ full
290
+ end
291
+
292
+ def encode_params(params)
293
+ if defined?(Rack::Utils) && Rack::Utils.respond_to?(:build_nested_query)
294
+ Rack::Utils.build_nested_query(params)
295
+ else
296
+ URI.encode_www_form(flatten_params(params))
297
+ end
298
+ end
299
+
300
+ def flatten_params(params, parent_key = nil)
301
+ params.flat_map do |k, v|
302
+ full_key = parent_key ? "#{parent_key}[#{k}]" : k.to_s
303
+ if v.is_a?(Hash)
304
+ flatten_params(v, full_key)
305
+ elsif v.is_a?(Array)
306
+ v.map { |item| ["#{full_key}[]", item] }
307
+ else
308
+ [[full_key, v]]
309
+ end
310
+ end
311
+ end
312
+
313
+ def find_executable(name)
314
+ return name if name.include?(File::SEPARATOR) && executable?(name)
315
+
316
+ # Search in system PATH
317
+ paths = ENV['PATH'].to_s.split(File::PATH_SEPARATOR) + COMMON_SEARCH_PATHS
318
+ paths.uniq.each do |dir|
319
+ next unless dir && Dir.exist?(dir)
320
+ full_path = File.join(dir, name)
321
+ return full_path if executable?(full_path)
322
+ end
323
+
324
+ nil
325
+ end
326
+
327
+ def executable?(path)
328
+ File.file?(path) && File.executable?(path)
329
+ rescue StandardError
330
+ false
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpMimic
4
+ class Configuration
5
+ attr_accessor :default_impersonate
6
+ attr_accessor :binary_path
7
+ attr_accessor :fallback_to_curl
8
+ attr_accessor :default_timeout
9
+ attr_accessor :default_connect_timeout
10
+ attr_accessor :follow_redirects
11
+ attr_accessor :max_redirects
12
+ attr_accessor :raise_on_error
13
+ attr_accessor :logger
14
+ attr_accessor :debug
15
+
16
+ # Webdrivers-like auto download and driver management settings
17
+ attr_accessor :auto_download
18
+ attr_accessor :driver_version
19
+ attr_accessor :install_dir
20
+ attr_accessor :github_repo
21
+
22
+ def initialize
23
+ @default_impersonate = 'chrome131'
24
+ @binary_path = nil
25
+ @fallback_to_curl = true
26
+ @default_timeout = 30
27
+ @default_connect_timeout = 10
28
+ @follow_redirects = true
29
+ @max_redirects = 10
30
+ @raise_on_error = false
31
+ @logger = nil
32
+ @debug = false
33
+
34
+ # Enable automatic downloading of curl-impersonate driver (lexiforest) by default
35
+ @auto_download = true
36
+ @driver_version = 'v2.1.1'
37
+ @install_dir = File.expand_path('~/.http_mimic/bin')
38
+ @github_repo = 'lexiforest/curl-impersonate'
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpMimic
4
+ class Cookies
5
+ include Enumerable
6
+
7
+ CookieItem = Struct.new(:name, :value, :attributes)
8
+
9
+ attr_reader :store
10
+
11
+ def initialize(cookie_hash = {})
12
+ @store = {}
13
+ @cookie_items = {}
14
+
15
+ cookie_hash.each do |k, v|
16
+ self[k] = v
17
+ end
18
+ end
19
+
20
+ def [](name)
21
+ @store[name.to_s]
22
+ end
23
+
24
+ def []=(name, value)
25
+ @store[name.to_s] = value.to_s
26
+ end
27
+
28
+ def item(name)
29
+ @cookie_items[name.to_s]
30
+ end
31
+
32
+ def key?(name)
33
+ @store.key?(name.to_s)
34
+ end
35
+ alias has_key? key?
36
+ alias include? key?
37
+
38
+ def each(&block)
39
+ @store.each(&block)
40
+ end
41
+
42
+ def keys
43
+ @store.keys
44
+ end
45
+
46
+ def values
47
+ @store.values
48
+ end
49
+
50
+ def to_h
51
+ @store.dup
52
+ end
53
+ alias to_hash to_h
54
+
55
+ def to_cookie_string
56
+ @store.map { |k, v| "#{k}=#{v}" }.join('; ')
57
+ end
58
+
59
+ def inspect
60
+ "#<#{self.class.name} #{@store.inspect}>"
61
+ end
62
+
63
+ def to_s
64
+ to_cookie_string
65
+ end
66
+
67
+ # Parse a Cookies object from Set-Cookie headers
68
+ def self.parse_set_cookie(header_value_or_array)
69
+ cookies = new
70
+ headers = Array(header_value_or_array).flatten.compact
71
+
72
+ headers.each do |header_str|
73
+ parts = header_str.split(';').map(&:strip)
74
+ next if parts.empty?
75
+
76
+ name_val = parts.first
77
+ key, val = name_val.split('=', 2)
78
+ next unless key
79
+
80
+ key = key.strip
81
+ val = val ? val.strip : ''
82
+
83
+ attributes = {}
84
+ parts[1..-1].each do |attr_str|
85
+ attr_k, attr_v = attr_str.split('=', 2)
86
+ attr_k = attr_k.strip.downcase
87
+ attributes[attr_k] = attr_v ? attr_v.strip : true
88
+ end
89
+
90
+ cookies[key] = val
91
+ cookies.instance_variable_get(:@cookie_items)[key] = CookieItem.new(key, val, attributes)
92
+ end
93
+
94
+ cookies
95
+ end
96
+
97
+ # Format Hash, Cookies, or String into a curl -b cookie string
98
+ def self.format(cookie_input)
99
+ case cookie_input
100
+ when Hash
101
+ cookie_input.map { |k, v| "#{k}=#{v}" }.join('; ')
102
+ when Cookies
103
+ cookie_input.to_cookie_string
104
+ when String
105
+ cookie_input
106
+ else
107
+ cookie_input.to_s
108
+ end
109
+ end
110
+ end
111
+ end