http_mimic 0.4.0 → 0.5.3
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 +64 -0
- data/README.md +117 -33
- data/lib/http_mimic/command_builder.rb +17 -1
- data/lib/http_mimic/configuration.rb +33 -0
- data/lib/http_mimic/downloader.rb +97 -0
- data/lib/http_mimic/module_methods.rb +39 -0
- data/lib/http_mimic/obscura.rb +158 -0
- data/lib/http_mimic/proxy_pool.rb +224 -0
- data/lib/http_mimic/request.rb +127 -11
- 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 +97 -39
- data/lib/http_mimic/waf/detector.rb +12 -0
- data/lib/http_mimic/waf/google_solver.rb +124 -0
- data/lib/http_mimic/waf.rb +3 -0
- data/lib/http_mimic.rb +27 -0
- metadata +5 -1
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
# Detect if the rendered output contains an explicit HTTP error block page
|
|
86
|
+
if code == 200 && stdout
|
|
87
|
+
if stdout.include?('HTTP 403 - Forbidden') ||
|
|
88
|
+
(stdout.include?('Reference Error:') && stdout.include?('Akamai')) ||
|
|
89
|
+
stdout.include?('"page_name": "403 ERROR"')
|
|
90
|
+
code = 403
|
|
91
|
+
elsif stdout.include?('404 Not Found') && stdout.include?('<title>404')
|
|
92
|
+
code = 404
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
status_msg = if code == 200
|
|
97
|
+
'OK (Obscura SPA Rendered)'
|
|
98
|
+
elsif code == 403
|
|
99
|
+
'Forbidden (WAF Blocked)'
|
|
100
|
+
else
|
|
101
|
+
'Error'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raw_headers = "HTTP/2 #{code} #{status_msg}\r\ncontent-type: text/html; charset=utf-8\r\nx-rendered-by: obscura\r\n\r\n"
|
|
105
|
+
headers = Headers.new({ 'content-type' => 'text/html; charset=utf-8', 'x-rendered-by' => 'obscura' })
|
|
106
|
+
|
|
107
|
+
Response.new(
|
|
108
|
+
code: code,
|
|
109
|
+
http_version: 'HTTP/2',
|
|
110
|
+
status_message: status_msg,
|
|
111
|
+
headers: headers,
|
|
112
|
+
cookies: Cookies.new,
|
|
113
|
+
body: stdout,
|
|
114
|
+
parsed_response: nil,
|
|
115
|
+
raw_headers: raw_headers,
|
|
116
|
+
history: [],
|
|
117
|
+
request_url: url.to_s,
|
|
118
|
+
stderr: stderr,
|
|
119
|
+
exit_code: status ? status.exitstatus : 0,
|
|
120
|
+
command: args.join(' ')
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def resolve_binary
|
|
125
|
+
# 1. Configured custom path
|
|
126
|
+
custom_path = HttpMimic.configuration.obscura_path
|
|
127
|
+
if custom_path && (File.file?(custom_path) || File.executable?(custom_path))
|
|
128
|
+
return custom_path
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# 2. Check install directory or system PATH
|
|
132
|
+
installed_path = Downloader.obscura_path
|
|
133
|
+
return installed_path if installed_path
|
|
134
|
+
|
|
135
|
+
# 3. Auto-download if enabled
|
|
136
|
+
if HttpMimic.configuration.auto_download
|
|
137
|
+
begin
|
|
138
|
+
return Downloader.download_obscura!
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
HttpMimic.configuration.logger&.warn("[HttpMimic::Obscura] Auto-download failed: #{e.message}")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def log_debug(msg)
|
|
150
|
+
if HttpMimic.configuration.logger
|
|
151
|
+
HttpMimic.configuration.logger.debug("[HttpMimic::Obscura] #{msg}")
|
|
152
|
+
elsif HttpMimic.configuration.debug
|
|
153
|
+
puts "[HttpMimic::Obscura] #{msg}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
module HttpMimic
|
|
8
|
+
class ProxyPool
|
|
9
|
+
DEFAULT_SOURCES = [
|
|
10
|
+
'https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt',
|
|
11
|
+
'https://api.proxyscrape.com/v2/?request=displayproxies&protocol=http&timeout=5000&country=all&ssl=all&anonymity=all',
|
|
12
|
+
'https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/http.txt'
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def instance
|
|
17
|
+
@instance ||= new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reset_instance!
|
|
21
|
+
@instance = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get
|
|
25
|
+
instance.get
|
|
26
|
+
end
|
|
27
|
+
alias sample get
|
|
28
|
+
alias next_proxy get
|
|
29
|
+
|
|
30
|
+
def mark_dead(proxy)
|
|
31
|
+
instance.mark_dead(proxy)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mark_alive(proxy)
|
|
35
|
+
instance.mark_alive(proxy)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def refresh!(force: true)
|
|
39
|
+
instance.refresh!(force: force)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def load(proxies)
|
|
43
|
+
instance.load(proxies)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def all
|
|
47
|
+
instance.all
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def available
|
|
51
|
+
instance.available
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dead_proxies
|
|
55
|
+
instance.dead_proxies
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def size
|
|
59
|
+
instance.size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear!
|
|
63
|
+
instance.clear!
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :sources, :ttl, :timeout
|
|
68
|
+
attr_accessor :proxies
|
|
69
|
+
|
|
70
|
+
def initialize(options = {})
|
|
71
|
+
config = HttpMimic.configuration rescue nil
|
|
72
|
+
@sources = options[:sources] || config&.proxy_sources || DEFAULT_SOURCES.dup
|
|
73
|
+
@ttl = options[:ttl] || config&.proxy_pool_ttl || 1800
|
|
74
|
+
@timeout = options[:timeout] || config&.proxy_timeout || 5
|
|
75
|
+
@proxies = []
|
|
76
|
+
@dead_proxies = Set.new
|
|
77
|
+
@last_fetched_at = nil
|
|
78
|
+
@mutex = Mutex.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Retrieve an available proxy from the pool
|
|
82
|
+
#
|
|
83
|
+
# @return [String, nil] Proxy URL (e.g. 'http://1.2.3.4:8080') or nil if none available
|
|
84
|
+
def get
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
refresh_unlocked(force: false) if should_refresh_unlocked?
|
|
87
|
+
avail = @proxies - @dead_proxies.to_a
|
|
88
|
+
if avail.empty? && !@proxies.empty?
|
|
89
|
+
# If all current proxies are exhausted/dead, clear dead set and try one more refresh
|
|
90
|
+
@dead_proxies.clear
|
|
91
|
+
refresh_unlocked(force: true)
|
|
92
|
+
avail = @proxies - @dead_proxies.to_a
|
|
93
|
+
end
|
|
94
|
+
avail.sample
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
alias sample get
|
|
98
|
+
alias next_proxy get
|
|
99
|
+
|
|
100
|
+
# Mark a proxy as dead/unusable
|
|
101
|
+
#
|
|
102
|
+
# @param proxy [String]
|
|
103
|
+
def mark_dead(proxy)
|
|
104
|
+
return unless proxy
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
@dead_proxies.add(normalize_proxy(proxy))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Mark a proxy as active/usable
|
|
111
|
+
#
|
|
112
|
+
# @param proxy [String]
|
|
113
|
+
def mark_alive(proxy)
|
|
114
|
+
return unless proxy
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
@dead_proxies.delete(normalize_proxy(proxy))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Explicitly refresh proxy pool from configured sources
|
|
121
|
+
def refresh!(force: true)
|
|
122
|
+
@mutex.synchronize do
|
|
123
|
+
refresh_unlocked(force: force)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Manually load a custom array of proxies
|
|
128
|
+
#
|
|
129
|
+
# @param proxy_list [Array<String>]
|
|
130
|
+
def load(proxy_list)
|
|
131
|
+
@mutex.synchronize do
|
|
132
|
+
@proxies = Array(proxy_list).map { |p| normalize_proxy(p) }.compact.uniq
|
|
133
|
+
@dead_proxies.clear
|
|
134
|
+
@last_fetched_at = Time.now
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns all loaded proxies
|
|
139
|
+
def all
|
|
140
|
+
@mutex.synchronize { @proxies.dup }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returns all non-dead available proxies
|
|
144
|
+
def available
|
|
145
|
+
@mutex.synchronize { (@proxies - @dead_proxies.to_a).dup }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Returns dead proxies
|
|
149
|
+
def dead_proxies
|
|
150
|
+
@mutex.synchronize { @dead_proxies.to_a }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Number of available proxies
|
|
154
|
+
def size
|
|
155
|
+
@mutex.synchronize { (@proxies - @dead_proxies.to_a).size }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Reset proxy pool
|
|
159
|
+
def clear!
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
@proxies.clear
|
|
162
|
+
@dead_proxies.clear
|
|
163
|
+
@last_fetched_at = nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def should_refresh_unlocked?
|
|
170
|
+
@proxies.empty? || @last_fetched_at.nil? || (Time.now - @last_fetched_at > @ttl)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def refresh_unlocked(force: false)
|
|
174
|
+
return if !force && !should_refresh_unlocked?
|
|
175
|
+
|
|
176
|
+
new_proxies = fetch_from_sources
|
|
177
|
+
if !new_proxies.empty?
|
|
178
|
+
@proxies = new_proxies
|
|
179
|
+
@last_fetched_at = Time.now
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def fetch_from_sources
|
|
184
|
+
collected = []
|
|
185
|
+
sources_to_try = Array(@sources).empty? ? DEFAULT_SOURCES : @sources
|
|
186
|
+
|
|
187
|
+
sources_to_try.each do |source_url|
|
|
188
|
+
begin
|
|
189
|
+
uri = URI.parse(source_url)
|
|
190
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
191
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
192
|
+
http.open_timeout = @timeout
|
|
193
|
+
http.read_timeout = @timeout
|
|
194
|
+
|
|
195
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
196
|
+
req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
|
197
|
+
|
|
198
|
+
res = http.request(req)
|
|
199
|
+
if res.is_a?(Net::HTTPSuccess) && res.body
|
|
200
|
+
# Extract IP:PORT matches
|
|
201
|
+
ips = res.body.scan(/\b(?:\d{1,3}\.){3}\d{1,3}:\d{2,5}\b/)
|
|
202
|
+
if !ips.empty?
|
|
203
|
+
collected.concat(ips.take(150))
|
|
204
|
+
# Stop early if we have collected enough proxies
|
|
205
|
+
break if collected.size >= 50
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
if HttpMimic.configuration.debug
|
|
210
|
+
puts "[HttpMimic::ProxyPool] Failed to fetch proxy list from #{source_url}: #{e.message}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
collected.uniq.map { |p| normalize_proxy(p) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def normalize_proxy(proxy)
|
|
219
|
+
p = proxy.to_s.strip
|
|
220
|
+
return nil if p.empty?
|
|
221
|
+
p.start_with?('http://', 'https://', 'socks5://', 'socks4://') ? p : "http://#{p}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
data/lib/http_mimic/request.rb
CHANGED
|
@@ -15,6 +15,22 @@ module HttpMimic
|
|
|
15
15
|
|
|
16
16
|
def perform
|
|
17
17
|
mode = (options[:mode] || config.mode || :auto).to_sym
|
|
18
|
+
|
|
19
|
+
# Automatic Free Proxy Pool integration
|
|
20
|
+
should_auto_proxy = options.fetch(:auto_proxy, config.auto_proxy)
|
|
21
|
+
if should_auto_proxy && !options[:proxy]
|
|
22
|
+
selected_proxy = ProxyPool.get
|
|
23
|
+
if selected_proxy
|
|
24
|
+
options[:proxy] = selected_proxy
|
|
25
|
+
log_debug("[HttpMimic::ProxyPool] Assigned proxy from pool: #{selected_proxy}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Delegate directly to Obscura headless SPA renderer if requested
|
|
30
|
+
if options[:render] == :spa || options[:render] == :obscura || mode == :spa || mode == :obscura
|
|
31
|
+
return Obscura.render(url, options)
|
|
32
|
+
end
|
|
33
|
+
|
|
18
34
|
auto_fallback = options.fetch(:auto_fallback, config.auto_fallback)
|
|
19
35
|
retry_statuses = options[:retry_statuses] || config.retry_statuses || [403, 429, 503]
|
|
20
36
|
|
|
@@ -34,6 +50,8 @@ module HttpMimic
|
|
|
34
50
|
attempts = []
|
|
35
51
|
profiles_to_try = determine_profiles(mode, auto_fallback)
|
|
36
52
|
waf_solve_attempted = false
|
|
53
|
+
best_response = nil
|
|
54
|
+
proxy_retries_left = should_auto_proxy ? (options[:proxy_retries] || config.proxy_retries || 3) : 0
|
|
37
55
|
|
|
38
56
|
response = nil
|
|
39
57
|
final_status = nil
|
|
@@ -41,6 +59,12 @@ module HttpMimic
|
|
|
41
59
|
final_command = nil
|
|
42
60
|
|
|
43
61
|
profiles_to_try.each_with_index do |profile, index|
|
|
62
|
+
# Never fall back to plain curl if a WAF challenge has already been detected/attempted
|
|
63
|
+
if profile == :curl && waf_solve_attempted
|
|
64
|
+
log_debug("[HttpMimic] Skipping plain :curl fallback for protected WAF target.")
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
44
68
|
current_opts = options.merge(profile: profile)
|
|
45
69
|
|
|
46
70
|
builder = CommandBuilder.new(method, url, current_opts, config)
|
|
@@ -55,6 +79,25 @@ module HttpMimic
|
|
|
55
79
|
log_debug("Curl exit status: #{status.exitstatus}")
|
|
56
80
|
log_debug("Curl stderr: #{stderr}") unless stderr.empty?
|
|
57
81
|
|
|
82
|
+
# Handle proxy failure retry when auto_proxy is enabled
|
|
83
|
+
if should_auto_proxy && current_opts[:proxy] && (status.exitstatus != 0)
|
|
84
|
+
is_proxy_err = [5, 7, 28, 35, 56].include?(status.exitstatus) ||
|
|
85
|
+
stderr.include?('Failed to connect to') ||
|
|
86
|
+
stderr.include?('tunneling failed') ||
|
|
87
|
+
stderr.downcase.include?('proxy')
|
|
88
|
+
|
|
89
|
+
if is_proxy_err
|
|
90
|
+
ProxyPool.mark_dead(current_opts[:proxy])
|
|
91
|
+
if proxy_retries_left > 0
|
|
92
|
+
proxy_retries_left -= 1
|
|
93
|
+
new_proxy = ProxyPool.get
|
|
94
|
+
log_debug("[HttpMimic::ProxyPool] Proxy #{current_opts[:proxy]} failed (exit #{status.exitstatus}). Retrying with new proxy #{new_proxy} (#{proxy_retries_left} retries left)...")
|
|
95
|
+
options[:proxy] = new_proxy
|
|
96
|
+
redo
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
58
101
|
response = ResponseParser.new(
|
|
59
102
|
stdout,
|
|
60
103
|
exit_status: status,
|
|
@@ -63,6 +106,10 @@ module HttpMimic
|
|
|
63
106
|
request_url: final_url
|
|
64
107
|
).parse
|
|
65
108
|
|
|
109
|
+
if should_auto_proxy && current_opts[:proxy] && response.success?
|
|
110
|
+
ProxyPool.mark_alive(current_opts[:proxy])
|
|
111
|
+
end
|
|
112
|
+
|
|
66
113
|
response.mode_used = profile
|
|
67
114
|
response.fallback_triggered = (index > 0)
|
|
68
115
|
|
|
@@ -80,6 +127,17 @@ module HttpMimic
|
|
|
80
127
|
final_stderr = stderr
|
|
81
128
|
final_command = full_command
|
|
82
129
|
|
|
130
|
+
# Track best response so a 200 (or challenge page) isn't wiped out by a failed fallback
|
|
131
|
+
if response
|
|
132
|
+
if best_response.nil?
|
|
133
|
+
best_response = response
|
|
134
|
+
elsif response.success? && !best_response.success?
|
|
135
|
+
best_response = response
|
|
136
|
+
elsif response.code == 200 && best_response.code != 200
|
|
137
|
+
best_response = response
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
83
141
|
# Forward any received cookies to subsequent attempts
|
|
84
142
|
if response.cookies && !response.cookies.empty?
|
|
85
143
|
existing_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
|
|
@@ -98,10 +156,13 @@ module HttpMimic
|
|
|
98
156
|
solved_resp = Waf.solve(url, response, current_opts)
|
|
99
157
|
if solved_resp
|
|
100
158
|
response = solved_resp
|
|
101
|
-
is_blocked = (response.code != 0 && retry_statuses.include?(response.code))
|
|
159
|
+
is_blocked = (response.code != 0 && retry_statuses.include?(response.code)) || Waf::Detector.challenge_page?(response)
|
|
102
160
|
if response.cookies && !response.cookies.empty?
|
|
103
161
|
options[:cookies] = (options[:cookies] || {}).merge(response.cookies.to_h)
|
|
104
162
|
end
|
|
163
|
+
if response.success? || (response.code == 200 && best_response&.code != 200)
|
|
164
|
+
best_response = response
|
|
165
|
+
end
|
|
105
166
|
end
|
|
106
167
|
end
|
|
107
168
|
end
|
|
@@ -113,10 +174,61 @@ module HttpMimic
|
|
|
113
174
|
log_debug("[HttpMimic] Attempt #{index + 1} with #{profile} resulted in status #{response.code}. Triggering smart fallback to next profile...")
|
|
114
175
|
end
|
|
115
176
|
|
|
177
|
+
# Preserve best response if the last attempt resulted in a regression (e.g. 403 / error after getting 200)
|
|
178
|
+
if best_response && (response.nil? || response.error? || (final_status && final_status.exitstatus != 0))
|
|
179
|
+
if best_response.success? || (best_response.code == 200 && response&.code != 200)
|
|
180
|
+
response = best_response
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Automatic SPA Detection & Obscura rendering fallback for SPA shells & Behavioral Challenges
|
|
185
|
+
auto_render_spa = options.fetch(:auto_render_spa, config.auto_render_spa)
|
|
186
|
+
auto_solve_waf = options.fetch(:solve_waf, config.auto_solve_waf)
|
|
187
|
+
should_render_spa = auto_render_spa && response && response.success? && SpaDetector.spa?(response)
|
|
188
|
+
should_render_cpt = auto_solve_waf && response && Waf::Detector.challenge_page?(response)
|
|
189
|
+
|
|
190
|
+
if (should_render_spa || should_render_cpt) && method.to_s.upcase == 'GET'
|
|
191
|
+
target_reason = should_render_cpt ? 'WAF challenge page' : 'unhydrated SPA shell'
|
|
192
|
+
log_debug("[HttpMimic] Detected #{target_reason} on #{url}. Automatically rendering with Obscura...")
|
|
193
|
+
begin
|
|
194
|
+
spa_opts = options.dup
|
|
195
|
+
# Forward all validated cookies from Tier 1 (Mode 2: Two-Phase Pipeline)
|
|
196
|
+
if response.cookies && !response.cookies.empty?
|
|
197
|
+
tier1_cookies = response.cookies.to_h
|
|
198
|
+
existing_cookies = spa_opts[:cookies].is_a?(Hash) ? spa_opts[:cookies] : {}
|
|
199
|
+
spa_opts[:cookies] = existing_cookies.merge(tier1_cookies)
|
|
200
|
+
end
|
|
201
|
+
spa_opts[:wait_until] ||= 'load' if should_render_cpt
|
|
202
|
+
rendered_resp = Obscura.render(url, spa_opts)
|
|
203
|
+
if rendered_resp && rendered_resp.success?
|
|
204
|
+
response = rendered_resp
|
|
205
|
+
end
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
log_debug("[HttpMimic] Automatic Obscura render failed (#{e.message}), keeping Tier 1 response.")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
116
211
|
# Persist cookies back to store if enabled
|
|
117
|
-
if should_persist_cookie && host
|
|
118
|
-
|
|
119
|
-
|
|
212
|
+
if should_persist_cookie && host
|
|
213
|
+
is_success = response && (response.success? || response.redirect?) && !Waf::Detector.challenge_page?(response) && (final_status.nil? || final_status.exitstatus == 0)
|
|
214
|
+
verification_failed = !is_success && response && (
|
|
215
|
+
[401, 403].include?(response.code) ||
|
|
216
|
+
retry_statuses.include?(response.code) ||
|
|
217
|
+
Waf::Detector.challenge_page?(response)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
persist_on_failure = options.fetch(:persist_on_failure, config.persist_on_failure)
|
|
221
|
+
clear_on_failure = options.fetch(:clear_on_failure, config.clear_on_failure)
|
|
222
|
+
|
|
223
|
+
if is_success || persist_on_failure
|
|
224
|
+
if response && response.cookies && !response.cookies.empty?
|
|
225
|
+
CookieStore.save(host, response.cookies)
|
|
226
|
+
log_debug("[HttpMimic::CookieStore] Saved #{response.cookies.size} cookies for #{host}")
|
|
227
|
+
end
|
|
228
|
+
elsif clear_on_failure && verification_failed
|
|
229
|
+
CookieStore.clear(host)
|
|
230
|
+
log_debug("[HttpMimic::CookieStore] Verification failed for #{host} (status: #{response&.code}). Cleared stored cookies.")
|
|
231
|
+
end
|
|
120
232
|
end
|
|
121
233
|
|
|
122
234
|
handle_errors(final_status, final_stderr, final_command, response)
|
|
@@ -128,7 +240,7 @@ module HttpMimic
|
|
|
128
240
|
def determine_profiles(mode, auto_fallback)
|
|
129
241
|
case mode
|
|
130
242
|
when :curl, :curl_first
|
|
131
|
-
auto_fallback ? [:curl, :impersonate, :android, :ios] : [:curl]
|
|
243
|
+
auto_fallback ? [:curl, :impersonate, :android, :ios, :firefox] : [:curl]
|
|
132
244
|
when :curl_only
|
|
133
245
|
[:curl]
|
|
134
246
|
when :impersonate_only
|
|
@@ -139,18 +251,22 @@ module HttpMimic
|
|
|
139
251
|
[:android]
|
|
140
252
|
when :ios_only
|
|
141
253
|
[:ios]
|
|
254
|
+
when :firefox_only
|
|
255
|
+
[:firefox]
|
|
256
|
+
when :safari_only
|
|
257
|
+
[:safari]
|
|
142
258
|
when :mobile_first
|
|
143
|
-
auto_fallback ? [:android, :ios, :impersonate, :
|
|
259
|
+
auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:mobile]
|
|
144
260
|
when :android_first
|
|
145
|
-
auto_fallback ? [:android, :ios, :impersonate, :
|
|
261
|
+
auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:android]
|
|
146
262
|
when :ios_first
|
|
147
|
-
auto_fallback ? [:ios, :android, :impersonate, :
|
|
263
|
+
auto_fallback ? [:ios, :android, :impersonate, :firefox] : [:ios]
|
|
148
264
|
when :impersonate_first
|
|
149
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
265
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
150
266
|
when :auto, :smart
|
|
151
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
267
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
152
268
|
else
|
|
153
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
269
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
154
270
|
end
|
|
155
271
|
end
|
|
156
272
|
|
|
@@ -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