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.
@@ -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
@@ -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
- is_blocked = (status.exitstatus != 0) || retry_statuses.include?(response.code)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HttpMimic
4
- VERSION = "0.3.2"
4
+ VERSION = "0.5.1"
5
5
  end