http_mimic 0.3.2 → 0.5.1
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 +53 -0
- data/README.md +89 -1
- data/lib/http_mimic/command_builder.rb +109 -12
- data/lib/http_mimic/configuration.rb +47 -4
- data/lib/http_mimic/cookie_store.rb +118 -0
- data/lib/http_mimic/cookies.rb +38 -0
- data/lib/http_mimic/downloader.rb +199 -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 +16 -0
- data/lib/http_mimic/obscura.rb +139 -0
- data/lib/http_mimic/request.rb +75 -1
- data/lib/http_mimic/spa_detector.rb +95 -0
- 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 +73 -0
- data/lib/http_mimic/waf/google_solver.rb +124 -0
- data/lib/http_mimic/waf.rb +25 -0
- data/lib/http_mimic.rb +77 -0
- metadata +9 -1
|
@@ -13,6 +13,12 @@ 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
|
+
|
|
19
|
+
DEFAULT_OBSCURA_REPO = 'h4ckf0r0day/obscura'
|
|
20
|
+
DEFAULT_OBSCURA_VERSION = 'v0.2.1'
|
|
21
|
+
|
|
16
22
|
class DownloadError < HttpMimic::Error; end
|
|
17
23
|
class UnsupportedPlatformError < HttpMimic::Error; end
|
|
18
24
|
|
|
@@ -63,6 +69,199 @@ module HttpMimic
|
|
|
63
69
|
true
|
|
64
70
|
end
|
|
65
71
|
|
|
72
|
+
def download_qjs!(version: nil, install_dir: nil, repo: nil, force: false)
|
|
73
|
+
target_version = normalize_version(version || HttpMimic.configuration.qjs_version || DEFAULT_QJS_VERSION)
|
|
74
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
75
|
+
target_repo = repo || HttpMimic.configuration.qjs_github_repo || DEFAULT_QJS_REPO
|
|
76
|
+
|
|
77
|
+
FileUtils.mkdir_p(target_dir)
|
|
78
|
+
|
|
79
|
+
dest_binary = File.join(target_dir, binary_name_for_platform('qjs'))
|
|
80
|
+
|
|
81
|
+
if !force && qjs_installed?(version: target_version, install_dir: target_dir)
|
|
82
|
+
log_info("qjs #{target_version} is already installed in #{target_dir}")
|
|
83
|
+
return dest_binary
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
asset_name = qjs_platform_asset
|
|
87
|
+
download_url = "https://github.com/#{target_repo}/releases/download/#{target_version}/#{asset_name}"
|
|
88
|
+
|
|
89
|
+
log_info("Downloading QuickJS (#{target_version}) [#{asset_name}]...")
|
|
90
|
+
binary_data = fetch_binary(download_url)
|
|
91
|
+
|
|
92
|
+
File.binwrite(dest_binary, binary_data)
|
|
93
|
+
File.chmod(0755, dest_binary)
|
|
94
|
+
|
|
95
|
+
File.write(qjs_version_file_path(target_dir), target_version)
|
|
96
|
+
|
|
97
|
+
log_info("QuickJS #{target_version} installation complete! (#{dest_binary})")
|
|
98
|
+
dest_binary
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def qjs_installed?(version: nil, install_dir: nil)
|
|
102
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
103
|
+
qjs_bin = File.join(target_dir, binary_name_for_platform('qjs'))
|
|
104
|
+
return false unless File.file?(qjs_bin) && File.executable?(qjs_bin)
|
|
105
|
+
|
|
106
|
+
if version
|
|
107
|
+
target_version = normalize_version(version)
|
|
108
|
+
v_file = qjs_version_file_path(target_dir)
|
|
109
|
+
return false unless File.file?(v_file)
|
|
110
|
+
return File.read(v_file).strip == target_version
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def qjs_path(install_dir: nil)
|
|
117
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
118
|
+
candidate = File.join(target_dir, binary_name_for_platform('qjs'))
|
|
119
|
+
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
120
|
+
|
|
121
|
+
# Fallback to system PATH
|
|
122
|
+
sys_qjs = `which qjs 2>/dev/null`.strip
|
|
123
|
+
return sys_qjs if !sys_qjs.empty? && File.executable?(sys_qjs)
|
|
124
|
+
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def qjs_platform_asset
|
|
129
|
+
os = host_os
|
|
130
|
+
cpu = host_cpu
|
|
131
|
+
|
|
132
|
+
case os
|
|
133
|
+
when :macos
|
|
134
|
+
case cpu
|
|
135
|
+
when :arm64 then 'qjs-darwin-arm64'
|
|
136
|
+
when :x86_64 then 'qjs-darwin-x86_64'
|
|
137
|
+
else
|
|
138
|
+
raise UnsupportedPlatformError, "Unsupported macOS CPU architecture for QuickJS: #{cpu}"
|
|
139
|
+
end
|
|
140
|
+
when :linux
|
|
141
|
+
case cpu
|
|
142
|
+
when :x86_64
|
|
143
|
+
'qjs-linux-x86_64'
|
|
144
|
+
when :aarch64, :arm64
|
|
145
|
+
'qjs-linux-aarch64'
|
|
146
|
+
when :arm
|
|
147
|
+
'qjs-linux-armv7'
|
|
148
|
+
when :i386, :i686
|
|
149
|
+
'qjs-linux-x86'
|
|
150
|
+
when :riscv64
|
|
151
|
+
'qjs-linux-riscv64'
|
|
152
|
+
else
|
|
153
|
+
raise UnsupportedPlatformError, "Unsupported Linux CPU architecture for QuickJS: #{cpu}"
|
|
154
|
+
end
|
|
155
|
+
when :windows
|
|
156
|
+
case cpu
|
|
157
|
+
when :x86_64 then 'qjs-windows-x86_64.exe'
|
|
158
|
+
when :i386, :i686 then 'qjs-windows-x86.exe'
|
|
159
|
+
else
|
|
160
|
+
raise UnsupportedPlatformError, "Unsupported Windows CPU architecture for QuickJS: #{cpu}"
|
|
161
|
+
end
|
|
162
|
+
else
|
|
163
|
+
raise UnsupportedPlatformError, "Unsupported operating system for QuickJS: #{RbConfig::CONFIG['host_os']}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def qjs_version_file_path(dir)
|
|
168
|
+
File.join(dir, '.qjs_version')
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def download_obscura!(version: nil, install_dir: nil, repo: nil, force: false)
|
|
172
|
+
target_version = normalize_version(version || HttpMimic.configuration.obscura_version || DEFAULT_OBSCURA_VERSION)
|
|
173
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
174
|
+
target_repo = repo || HttpMimic.configuration.obscura_github_repo || DEFAULT_OBSCURA_REPO
|
|
175
|
+
|
|
176
|
+
FileUtils.mkdir_p(target_dir)
|
|
177
|
+
|
|
178
|
+
dest_binary = File.join(target_dir, binary_name_for_platform('obscura'))
|
|
179
|
+
|
|
180
|
+
if !force && obscura_installed?(version: target_version, install_dir: target_dir)
|
|
181
|
+
log_info("obscura #{target_version} is already installed in #{target_dir}")
|
|
182
|
+
return dest_binary
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
asset_name = obscura_platform_asset
|
|
186
|
+
download_url = "https://github.com/#{target_repo}/releases/download/#{target_version}/#{asset_name}"
|
|
187
|
+
|
|
188
|
+
log_info("Downloading Obscura (#{target_version}) [#{asset_name}]...")
|
|
189
|
+
archive_data = fetch_binary(download_url)
|
|
190
|
+
|
|
191
|
+
log_info("Extracting Obscura archive to #{target_dir}...")
|
|
192
|
+
extract_tar_gz(archive_data, target_dir)
|
|
193
|
+
|
|
194
|
+
# Ensure executable permissions on extracted binaries
|
|
195
|
+
%w[obscura obscura-worker].each do |name|
|
|
196
|
+
p = File.join(target_dir, binary_name_for_platform(name))
|
|
197
|
+
File.chmod(0755, p) if File.exist?(p)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
File.write(obscura_version_file_path(target_dir), target_version)
|
|
201
|
+
|
|
202
|
+
log_info("Obscura #{target_version} installation complete! (#{dest_binary})")
|
|
203
|
+
dest_binary
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def obscura_installed?(version: nil, install_dir: nil)
|
|
207
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
208
|
+
bin = File.join(target_dir, binary_name_for_platform('obscura'))
|
|
209
|
+
return false unless File.file?(bin) && File.executable?(bin)
|
|
210
|
+
|
|
211
|
+
if version
|
|
212
|
+
target_version = normalize_version(version)
|
|
213
|
+
v_file = obscura_version_file_path(target_dir)
|
|
214
|
+
return false unless File.file?(v_file)
|
|
215
|
+
return File.read(v_file).strip == target_version
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def obscura_path(install_dir: nil)
|
|
222
|
+
return HttpMimic.configuration.obscura_path if HttpMimic.configuration.obscura_path && File.executable?(HttpMimic.configuration.obscura_path)
|
|
223
|
+
|
|
224
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
225
|
+
candidate = File.join(target_dir, binary_name_for_platform('obscura'))
|
|
226
|
+
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
227
|
+
|
|
228
|
+
# Fallback to system PATH
|
|
229
|
+
sys_bin = `which obscura 2>/dev/null`.strip
|
|
230
|
+
return sys_bin if !sys_bin.empty? && File.executable?(sys_bin)
|
|
231
|
+
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def obscura_platform_asset
|
|
236
|
+
os = host_os
|
|
237
|
+
cpu = host_cpu
|
|
238
|
+
|
|
239
|
+
case os
|
|
240
|
+
when :macos
|
|
241
|
+
case cpu
|
|
242
|
+
when :arm64 then 'obscura-aarch64-macos-stealth.tar.gz'
|
|
243
|
+
when :x86_64 then 'obscura-x86_64-macos-stealth.tar.gz'
|
|
244
|
+
else
|
|
245
|
+
raise UnsupportedPlatformError, "Unsupported macOS CPU architecture for Obscura: #{cpu}"
|
|
246
|
+
end
|
|
247
|
+
when :linux
|
|
248
|
+
case cpu
|
|
249
|
+
when :x86_64 then 'obscura-x86_64-linux-stealth.tar.gz'
|
|
250
|
+
when :aarch64, :arm64 then 'obscura-aarch64-linux-stealth.tar.gz'
|
|
251
|
+
else
|
|
252
|
+
raise UnsupportedPlatformError, "Unsupported Linux CPU architecture for Obscura: #{cpu}"
|
|
253
|
+
end
|
|
254
|
+
when :windows
|
|
255
|
+
'obscura-x86_64-windows-stealth.zip'
|
|
256
|
+
else
|
|
257
|
+
raise UnsupportedPlatformError, "Unsupported operating system for Obscura: #{RbConfig::CONFIG['host_os']}"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def obscura_version_file_path(dir)
|
|
262
|
+
File.join(dir, '.obscura_version')
|
|
263
|
+
end
|
|
264
|
+
|
|
66
265
|
def binary_path(name, install_dir: nil)
|
|
67
266
|
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
68
267
|
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,16 @@ 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
|
+
|
|
49
|
+
def auto_render_spa(enabled = nil)
|
|
50
|
+
return default_options[:auto_render_spa] if enabled.nil?
|
|
51
|
+
default_options[:auto_render_spa] = enabled
|
|
52
|
+
end
|
|
53
|
+
|
|
44
54
|
def mode(m = nil)
|
|
45
55
|
return default_options[:mode] if m.nil?
|
|
46
56
|
default_options[:mode] = m
|
|
@@ -88,6 +98,12 @@ module HttpMimic
|
|
|
88
98
|
request(:options, url, options)
|
|
89
99
|
end
|
|
90
100
|
|
|
101
|
+
def render(url, options = {})
|
|
102
|
+
merged = default_options.merge(options)
|
|
103
|
+
Obscura.render(url, merged)
|
|
104
|
+
end
|
|
105
|
+
alias spa render
|
|
106
|
+
|
|
91
107
|
def request(method, url, options = {})
|
|
92
108
|
merged = default_options.merge(options)
|
|
93
109
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module HttpMimic
|
|
8
|
+
module Obscura
|
|
9
|
+
class ExecutionError < HttpMimic::Error; end
|
|
10
|
+
class BinaryNotFoundError < HttpMimic::Error; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Renders a web page using the Obscura headless SPA engine and returns an HttpMimic::Response
|
|
14
|
+
#
|
|
15
|
+
# @param url [String, URI] The target URL to render
|
|
16
|
+
# @param options [Hash] Rendering options
|
|
17
|
+
# @option options [String] :wait_until ('networkidle0') Wait condition (e.g. 'load', 'domcontentloaded', 'networkidle0')
|
|
18
|
+
# @option options [Integer] :timeout Per-command timeout in seconds
|
|
19
|
+
# @option options [String] :proxy Proxy URL (e.g. 'socks5://127.0.0.1:1080' or 'http://...')
|
|
20
|
+
# @option options [String] :eval JavaScript expression to evaluate on the page
|
|
21
|
+
# @option options [String] :dump Output format ('html', 'text', 'links')
|
|
22
|
+
# @return [HttpMimic::Response]
|
|
23
|
+
def render(url, options = {})
|
|
24
|
+
bin = resolve_binary
|
|
25
|
+
raise BinaryNotFoundError, "Obscura binary not found. Run HttpMimic.download_obscura! or configure obscura_path." unless bin
|
|
26
|
+
|
|
27
|
+
args = [bin, 'fetch', url.to_s]
|
|
28
|
+
|
|
29
|
+
# Dump format: default to html (can be 'html', 'text', 'links', 'markdown', 'original', 'cookies')
|
|
30
|
+
dump_format = options[:dump] || 'html'
|
|
31
|
+
args << '--dump' << dump_format.to_s
|
|
32
|
+
|
|
33
|
+
# Stealth mode (enabled by default)
|
|
34
|
+
args << '--stealth' if options.fetch(:stealth, true)
|
|
35
|
+
|
|
36
|
+
# Wait condition
|
|
37
|
+
if options[:wait_until]
|
|
38
|
+
args << '--wait-until' << options[:wait_until].to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Wait delay
|
|
42
|
+
if options[:wait]
|
|
43
|
+
args << '--wait' << options[:wait].to_i.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Timeout in seconds (integer)
|
|
47
|
+
timeout = options[:timeout] || HttpMimic.configuration.default_timeout
|
|
48
|
+
args << '--timeout' << timeout.to_i.to_s if timeout
|
|
49
|
+
|
|
50
|
+
# User Agent
|
|
51
|
+
if options[:user_agent]
|
|
52
|
+
args << '--user-agent' << options[:user_agent].to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Proxy
|
|
56
|
+
if options[:proxy]
|
|
57
|
+
args << '--proxy' << options[:proxy].to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Cookies: pass cookies string or hash (Two-Phase Pipeline support)
|
|
61
|
+
if options[:cookies] || options[:cookie]
|
|
62
|
+
cookie_val = options[:cookies] || options[:cookie]
|
|
63
|
+
cookie_str = if cookie_val.is_a?(Hash)
|
|
64
|
+
cookie_val.map { |k, v| "#{k}=#{v}" }.join('; ')
|
|
65
|
+
elsif cookie_val.is_a?(Array)
|
|
66
|
+
cookie_val.join('; ')
|
|
67
|
+
else
|
|
68
|
+
cookie_val.to_s
|
|
69
|
+
end
|
|
70
|
+
args << '--cookie' << cookie_str unless cookie_str.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Optional JS evaluation
|
|
74
|
+
args << '--eval' << options[:eval].to_s if options[:eval]
|
|
75
|
+
|
|
76
|
+
# Selector
|
|
77
|
+
args << '--selector' << options[:selector].to_s if options[:selector]
|
|
78
|
+
|
|
79
|
+
log_debug("Executing Obscura SPA render: #{args.join(' ')}")
|
|
80
|
+
|
|
81
|
+
stdout, stderr, status = Open3.capture3(*args)
|
|
82
|
+
|
|
83
|
+
code = (status && status.success?) ? 200 : (status ? status.exitstatus : 500)
|
|
84
|
+
|
|
85
|
+
raw_headers = "HTTP/2 200 OK\r\ncontent-type: text/html; charset=utf-8\r\nx-rendered-by: obscura\r\n\r\n"
|
|
86
|
+
headers = Headers.new({ 'content-type' => 'text/html; charset=utf-8', 'x-rendered-by' => 'obscura' })
|
|
87
|
+
|
|
88
|
+
Response.new(
|
|
89
|
+
code: code,
|
|
90
|
+
http_version: 'HTTP/2',
|
|
91
|
+
status_message: status&.success? ? 'OK (Obscura SPA Rendered)' : 'Error',
|
|
92
|
+
headers: headers,
|
|
93
|
+
cookies: Cookies.new,
|
|
94
|
+
body: stdout,
|
|
95
|
+
parsed_response: nil,
|
|
96
|
+
raw_headers: raw_headers,
|
|
97
|
+
history: [],
|
|
98
|
+
request_url: url.to_s,
|
|
99
|
+
stderr: stderr,
|
|
100
|
+
exit_code: status ? status.exitstatus : 0,
|
|
101
|
+
command: args.join(' ')
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_binary
|
|
106
|
+
# 1. Configured custom path
|
|
107
|
+
custom_path = HttpMimic.configuration.obscura_path
|
|
108
|
+
if custom_path && (File.file?(custom_path) || File.executable?(custom_path))
|
|
109
|
+
return custom_path
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 2. Check install directory or system PATH
|
|
113
|
+
installed_path = Downloader.obscura_path
|
|
114
|
+
return installed_path if installed_path
|
|
115
|
+
|
|
116
|
+
# 3. Auto-download if enabled
|
|
117
|
+
if HttpMimic.configuration.auto_download
|
|
118
|
+
begin
|
|
119
|
+
return Downloader.download_obscura!
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
HttpMimic.configuration.logger&.warn("[HttpMimic::Obscura] Auto-download failed: #{e.message}")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def log_debug(msg)
|
|
131
|
+
if HttpMimic.configuration.logger
|
|
132
|
+
HttpMimic.configuration.logger.debug("[HttpMimic::Obscura] #{msg}")
|
|
133
|
+
elsif HttpMimic.configuration.debug
|
|
134
|
+
puts "[HttpMimic::Obscura] #{msg}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/http_mimic/request.rb
CHANGED
|
@@ -15,11 +15,31 @@ module HttpMimic
|
|
|
15
15
|
|
|
16
16
|
def perform
|
|
17
17
|
mode = (options[:mode] || config.mode || :auto).to_sym
|
|
18
|
+
|
|
19
|
+
# Delegate directly to Obscura headless SPA renderer if requested
|
|
20
|
+
if options[:render] == :spa || options[:render] == :obscura || mode == :spa || mode == :obscura
|
|
21
|
+
return Obscura.render(url, options)
|
|
22
|
+
end
|
|
23
|
+
|
|
18
24
|
auto_fallback = options.fetch(:auto_fallback, config.auto_fallback)
|
|
19
25
|
retry_statuses = options[:retry_statuses] || config.retry_statuses || [403, 429, 503]
|
|
20
26
|
|
|
27
|
+
# Persistent CookieStore integration
|
|
28
|
+
should_persist_cookie = options.fetch(:persist_cookies, config.persist_cookies)
|
|
29
|
+
host = CookieStore.extract_host(url)
|
|
30
|
+
|
|
31
|
+
if should_persist_cookie && host
|
|
32
|
+
stored_cookies = CookieStore.load(host)
|
|
33
|
+
if stored_cookies && !stored_cookies.empty?
|
|
34
|
+
log_debug("[HttpMimic::CookieStore] Loaded #{stored_cookies.size} persistent cookies for #{host}")
|
|
35
|
+
user_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
|
|
36
|
+
options[:cookies] = stored_cookies.merge(user_cookies)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
21
40
|
attempts = []
|
|
22
41
|
profiles_to_try = determine_profiles(mode, auto_fallback)
|
|
42
|
+
waf_solve_attempted = false
|
|
23
43
|
|
|
24
44
|
response = nil
|
|
25
45
|
final_status = nil
|
|
@@ -66,7 +86,32 @@ module HttpMimic
|
|
|
66
86
|
final_stderr = stderr
|
|
67
87
|
final_command = full_command
|
|
68
88
|
|
|
69
|
-
|
|
89
|
+
# Forward any received cookies to subsequent attempts
|
|
90
|
+
if response.cookies && !response.cookies.empty?
|
|
91
|
+
existing_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
|
|
92
|
+
options[:cookies] = response.cookies.to_h.merge(existing_cookies)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
auto_solve_waf = options.fetch(:solve_waf, config.auto_solve_waf)
|
|
96
|
+
is_blocked = (status.exitstatus != 0) || retry_statuses.include?(response.code) || Waf::Detector.challenge_page?(response)
|
|
97
|
+
|
|
98
|
+
# Only attempt WAF resolution once per request to avoid unnecessary latency on subsequent fallbacks
|
|
99
|
+
if is_blocked && auto_solve_waf && !waf_solve_attempted && method.to_s.upcase == 'GET'
|
|
100
|
+
waf_type = Waf::Detector.detect(response)
|
|
101
|
+
if waf_type
|
|
102
|
+
waf_solve_attempted = true
|
|
103
|
+
log_debug("[HttpMimic] Detected #{waf_type.to_s.capitalize} WAF challenge. Attempting to solve with QuickJS...")
|
|
104
|
+
solved_resp = Waf.solve(url, response, current_opts)
|
|
105
|
+
if solved_resp
|
|
106
|
+
response = solved_resp
|
|
107
|
+
is_blocked = (response.code != 0 && retry_statuses.include?(response.code))
|
|
108
|
+
if response.cookies && !response.cookies.empty?
|
|
109
|
+
options[:cookies] = (options[:cookies] || {}).merge(response.cookies.to_h)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
70
115
|
if !is_blocked || (index == profiles_to_try.size - 1)
|
|
71
116
|
break
|
|
72
117
|
end
|
|
@@ -74,6 +119,35 @@ module HttpMimic
|
|
|
74
119
|
log_debug("[HttpMimic] Attempt #{index + 1} with #{profile} resulted in status #{response.code}. Triggering smart fallback to next profile...")
|
|
75
120
|
end
|
|
76
121
|
|
|
122
|
+
# Automatic SPA Detection & Obscura rendering fallback
|
|
123
|
+
auto_render_spa = options.fetch(:auto_render_spa, config.auto_render_spa)
|
|
124
|
+
if auto_render_spa && response && response.success? && method.to_s.upcase == 'GET'
|
|
125
|
+
if SpaDetector.spa?(response)
|
|
126
|
+
log_debug("[HttpMimic] Detected unhydrated SPA shell on #{url}. Automatically rendering with Obscura...")
|
|
127
|
+
begin
|
|
128
|
+
spa_opts = options.dup
|
|
129
|
+
# Forward all validated cookies from Tier 1 (Mode 2: Two-Phase Pipeline)
|
|
130
|
+
if response.cookies && !response.cookies.empty?
|
|
131
|
+
tier1_cookies = response.cookies.to_h
|
|
132
|
+
existing_cookies = spa_opts[:cookies].is_a?(Hash) ? spa_opts[:cookies] : {}
|
|
133
|
+
spa_opts[:cookies] = existing_cookies.merge(tier1_cookies)
|
|
134
|
+
end
|
|
135
|
+
rendered_resp = Obscura.render(url, spa_opts)
|
|
136
|
+
if rendered_resp && rendered_resp.success?
|
|
137
|
+
response = rendered_resp
|
|
138
|
+
end
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
log_debug("[HttpMimic] Automatic Obscura SPA render failed (#{e.message}), keeping Tier 1 response.")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Persist cookies back to store if enabled
|
|
146
|
+
if should_persist_cookie && host && response && response.cookies && !response.cookies.empty?
|
|
147
|
+
CookieStore.save(host, response.cookies)
|
|
148
|
+
log_debug("[HttpMimic::CookieStore] Saved #{response.cookies.size} cookies for #{host}")
|
|
149
|
+
end
|
|
150
|
+
|
|
77
151
|
handle_errors(final_status, final_stderr, final_command, response)
|
|
78
152
|
response
|
|
79
153
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpMimic
|
|
4
|
+
module SpaDetector
|
|
5
|
+
# Framework mount points that indicate an unhydrated SPA client shell
|
|
6
|
+
EMPTY_MOUNT_REGEX = %r{
|
|
7
|
+
<div[^>]+id=["'](?:root|app|__next|__nuxt|svelte)["'][^>]*>\s*</div>|
|
|
8
|
+
<app-root[^>]*>\s*</app-root>|
|
|
9
|
+
<div[^>]+id=["']app["'][^>]*>\s*<!--\s*(?:app-content)?\s*-->\s*</div>
|
|
10
|
+
}ix
|
|
11
|
+
|
|
12
|
+
# Noscript patterns requiring JavaScript execution
|
|
13
|
+
NOSCRIPT_JS_REQUIRED_REGEX = %r{
|
|
14
|
+
<noscript[^>]*>[\s\S]*?(?:enable\s+javascript|javascript\s+is\s+required|need\s+to\s+enable\s+javascript|requires\s+javascript)[\s\S]*?</noscript>
|
|
15
|
+
}ix
|
|
16
|
+
|
|
17
|
+
# Google Search Guard dynamic SPA shell indicators
|
|
18
|
+
GOOGLE_SHELL_REGEX = %r{
|
|
19
|
+
(?:/httpservice/retry/enablejs|SG_REL|emsg=SG_REL|knitsail)
|
|
20
|
+
}ix
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Returns true if the HTTP response represents an unhydrated Single Page Application (SPA) shell
|
|
24
|
+
#
|
|
25
|
+
# @param response [HttpMimic::Response]
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def spa?(response)
|
|
28
|
+
return false unless response && response.success?
|
|
29
|
+
return false if response.respond_to?(:binary?) && response.binary?
|
|
30
|
+
|
|
31
|
+
content_type = response.headers['content-type'].to_s.downcase
|
|
32
|
+
return false unless content_type.empty? || content_type.include?('text/html') || content_type.include?('application/xhtml')
|
|
33
|
+
|
|
34
|
+
body = response.body.to_s
|
|
35
|
+
return false if body.strip.empty?
|
|
36
|
+
|
|
37
|
+
# 1. Google Dynamic SERP / Search Guard JS Shell
|
|
38
|
+
if google_serp_shell?(body)
|
|
39
|
+
return true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 2. Modern SPA framework empty mount points (React, Vue, Angular, Next, Nuxt, Svelte)
|
|
43
|
+
if empty_mount_point?(body)
|
|
44
|
+
return true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 3. Noscript requirement message
|
|
48
|
+
if noscript_js_required?(body)
|
|
49
|
+
return true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 4. Small body with multiple scripts and virtually no visible text
|
|
53
|
+
if low_text_js_heavy_shell?(body)
|
|
54
|
+
return true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def empty_mount_point?(body)
|
|
61
|
+
body.match?(EMPTY_MOUNT_REGEX)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def noscript_js_required?(body)
|
|
65
|
+
body.match?(NOSCRIPT_JS_REQUIRED_REGEX)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def google_serp_shell?(body)
|
|
69
|
+
return false unless body.match?(GOOGLE_SHELL_REGEX)
|
|
70
|
+
|
|
71
|
+
# If it has the Google shell indicators AND lacks rendered organic result elements
|
|
72
|
+
!body.include?('<h3 class=') && !body.include?('id="search"') && !body.include?('id="rso"')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def low_text_js_heavy_shell?(body)
|
|
76
|
+
return false if body.length > 25_000 # Larger HTML files typically have SSR content
|
|
77
|
+
|
|
78
|
+
# Check if there are script tags
|
|
79
|
+
script_count = body.scan(/<script/i).size
|
|
80
|
+
return false if script_count.zero?
|
|
81
|
+
|
|
82
|
+
# Strip all HTML tags, scripts, and styles to get raw visible text length
|
|
83
|
+
visible_text = body
|
|
84
|
+
.gsub(/<script[\s\S]*?<\/script>/i, '')
|
|
85
|
+
.gsub(/<style[\s\S]*?<\/style>/i, '')
|
|
86
|
+
.gsub(/<[^>]+>/, ' ')
|
|
87
|
+
.gsub(/\s+/, ' ')
|
|
88
|
+
.strip
|
|
89
|
+
|
|
90
|
+
# If visible text is virtually nonexistent but page has multiple scripts
|
|
91
|
+
visible_text.length < 80 && script_count >= 2
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/http_mimic/version.rb
CHANGED