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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +74 -16
- data/lib/http_mimic/command_builder.rb +160 -36
- data/lib/http_mimic/configuration.rb +50 -19
- data/lib/http_mimic/cookie_store.rb +118 -0
- data/lib/http_mimic/cookies.rb +38 -0
- data/lib/http_mimic/downloader.rb +102 -0
- data/lib/http_mimic/exceptions.rb +15 -0
- data/lib/http_mimic/js_runtime.rb +78 -0
- data/lib/http_mimic/module_methods.rb +5 -0
- data/lib/http_mimic/request.rb +62 -5
- data/lib/http_mimic/response.rb +84 -0
- data/lib/http_mimic/response_parser.rb +45 -21
- data/lib/http_mimic/version.rb +1 -1
- data/lib/http_mimic/waf/akamai_solver.rb +123 -0
- data/lib/http_mimic/waf/detector.rb +61 -0
- data/lib/http_mimic/waf.rb +22 -0
- data/lib/http_mimic.rb +51 -0
- metadata +6 -1
data/lib/http_mimic/cookies.rb
CHANGED
|
@@ -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
|
data/lib/http_mimic/request.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/http_mimic/response.rb
CHANGED
|
@@ -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(/&/, '&').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,
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
94
|
+
delim_pos = lf_idx
|
|
95
|
+
delim_len = 2
|
|
83
96
|
end
|
|
84
|
-
end
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
data/lib/http_mimic/version.rb
CHANGED