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.
@@ -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
@@ -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 && response && response.cookies && !response.cookies.empty?
118
- CookieStore.save(host, response.cookies)
119
- log_debug("[HttpMimic::CookieStore] Saved #{response.cookies.size} cookies for #{host}")
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, :curl] : [:mobile]
259
+ auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:mobile]
144
260
  when :android_first
145
- auto_fallback ? [:android, :ios, :impersonate, :curl] : [:android]
261
+ auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:android]
146
262
  when :ios_first
147
- auto_fallback ? [:ios, :android, :impersonate, :curl] : [:ios]
263
+ auto_fallback ? [:ios, :android, :impersonate, :firefox] : [:ios]
148
264
  when :impersonate_first
149
- auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
265
+ auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
150
266
  when :auto, :smart
151
- auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
267
+ auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
152
268
  else
153
- auto_fallback ? [:impersonate, :android, :ios, :curl] : [:impersonate]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HttpMimic
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.3"
5
5
  end