http_mimic 0.4.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b0a9fff1b74c98ea24c294a491b00ba57e270d24e6c0bb7a669e6d2db264d8a
4
- data.tar.gz: e11d3ae615b92abcfedda8547120290d82020acd82230353a127cc97dfc844f6
3
+ metadata.gz: b09e014134575dac987366c71b0cb6644a13a5b17088947621474f6cfccec5cf
4
+ data.tar.gz: 748d8d3dadcb69d39312a6c7137c32cca1290b7eaee4d8641fa7bb58bfdd0f80
5
5
  SHA512:
6
- metadata.gz: 5e0a93bb67bb84582e24df07ea4d51d291a53f0b48ab77ad0bd4a63b666bede4b493bbb405ad34d76602e086eafd76a455668c2a491488d83dd2c0be1060fe66
7
- data.tar.gz: c616cafefeb23d78af7320beab5ddc3b8f79aafda440fd1fac889f5a5990417b31716105e8c74cab566e6c0ed6eca04a82a397ddc4242eb59caed519e52739de
6
+ metadata.gz: a4d17399d5f85df9b508151f371b0910e074d70e3c05f11cb291f30f01b71c20d22e998ec5c5466728c77dd2f37b3de4aecc18883a844fb69952ae71a3a5c5be
7
+ data.tar.gz: 7b0675e724e1c138cd57ea4af5e5a6ce2f057bb50981271f9b81cb894bf7179c2ba760137f0672d742a63b602783bc6b455a71b9da4f778c0637db96ee723470
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.1] - 2026-08-30
9
+
10
+ ### Added
11
+ - **Two-Phase Cookie & State Pipeline (Tier 1 ➔ Tier 2)**:
12
+ - Automatically forward validated cookies (`NID`, `AEC`, `_abck`, `cf_clearance`) and custom headers from Tier 1 HTTP / WAF resolution directly into Obscura (`--cookie` flag) during `auto_render_spa` transitions.
13
+ - **Obscura Stealth BoringSSL Integration**:
14
+ - Downloader now defaults to `*-stealth.tar.gz` release assets, equipping Obscura with native BoringSSL Chrome TLS / JA4 fingerprint impersonation.
15
+ - **Upgraded Obscura to `anxgang/obscura` v0.2.3**:
16
+ - WebGL context, `REAL_FONT_METRICS` font measurements, and persistent Web Worker `importScripts` support.
17
+
18
+ ## [0.5.0] - 2026-08-30
19
+
20
+ ### Added
21
+ - **Tier 2: Obscura Headless SPA Rendering Support**:
22
+ - Native integration with [`h4ckf0r0day/obscura`](https://github.com/h4ckf0r0day/obscura)—a lightweight (<100MB) Rust + V8 headless browser engine with built-in stealth anti-detection.
23
+ - On-demand automatic driver management (`HttpMimic.download_obscura!`, `HttpMimic.obscura_installed?`, `HttpMimic.obscura_path`) across macOS (ARM64/x86_64), Linux (x86_64/aarch64), and Windows.
24
+ - New rendering helpers: `HttpMimic.render(url, options = {})` and `HttpMimic.spa(url, options = {})`.
25
+ - **Automatic SPA Detection & Transition (`auto_render_spa`)**:
26
+ - Intelligent `HttpMimic::SpaDetector` detects unhydrated client-side SPA shells (React `#root`, Vue `#app`, Angular `<app-root>`, Google Dynamic SERP, `<noscript>` prompts).
27
+ - When `auto_render_spa: true` (or `config.auto_render_spa = true`) is enabled, `HttpMimic.get` automatically transitions from Tier 1 (HTTP) to Tier 2 (Obscura) to render the full dynamic DOM.
28
+ - Configurable options: `:wait_until` (`networkidle0`, `domcontentloaded`, `load`), `:timeout`, `:proxy`, `:eval`, `:dump`, `:selector`, `:user_agent`, and `:stealth`.
29
+
30
+ ## [0.4.1] - 2026-08-30
31
+
32
+ ### Added
33
+ - **Google Search Guard & BotGuard VM Detection**:
34
+ - Added `HttpMimic::Waf::GoogleSolver` and `HttpMimic::Waf::Detector.google?` for detecting Google Search Guard (`knitsail`, `enablejs`, `SG_SS`) challenges.
35
+ - Added automatic Google Search referral context (`Referer: https://www.google.com/`, `sec-fetch-site: same-origin`) in `CommandBuilder#apply_navigation_headers`.
36
+ - **Authentic Hardware & WebGL Pipeline Emulation in QuickJS Context**:
37
+ - Full WebGL shader compilation and rasterization pipeline (`createShader`, `compileShader`, `getShaderPrecisionFormat`, `createProgram`, `linkProgram`, `readPixels` simulated gradient buffer).
38
+ - Dynamic font measurement (`REAL_FONT_METRICS`) with exact macOS Chrome `offsetWidth` / `offsetHeight` dimensions across 17 font families and 7 font sizes.
39
+ - Authentic Chromium `OfflineAudioContext` audio buffer rendering curve.
40
+ - Physics-based Cubic Bézier Spline mouse movement simulation with velocity decay, natural micro-jitter, and complete click event chains.
41
+ - Polyfills for `trustedTypes`, `sessionStorage`, `localStorage`, and `document.currentScript`.
42
+ - **Automatic Response Decompression**:
43
+ - Added `--compressed` flag to CommandBuilder to ensure automatic transparent decompression of `gzip`, `deflate`, `br` (Brotli), and `zstd` payloads.
44
+
8
45
  ## [0.4.0] - 2026-08-30
9
46
 
10
47
  ### Added
data/README.md CHANGED
@@ -287,6 +287,47 @@ end
287
287
  | `:bearer_token` | String | Appends `Authorization: Bearer <token>` header |
288
288
  | `:insecure` | Boolean | Disable SSL certificate verification (`-k`) |
289
289
  | `:curl_options` | Array / String | Additional raw curl arguments (e.g., `['--http2', '--compressed']`) |
290
+ | `:auto_render_spa` | Boolean | Automatically detect unhydrated SPA shells and render via Obscura |
291
+ | `:render` | Symbol | `:spa` or `:obscura` to explicitly render page via Obscura |
292
+
293
+ ---
294
+
295
+ ## ⚡ Obscura SPA Rendering (Tier 2 Engine)
296
+
297
+ `http_mimic` provides native support for [`h4ckf0r0day/obscura`](https://github.com/h4ckf0r0day/obscura)—a lightweight, single-binary (<100MB) headless browser engine written in Rust with an embedded V8 JavaScript runtime and built-in stealth anti-detection capabilities.
298
+
299
+ Obscura is auto-downloaded on-demand into `~/.http_mimic/bin/obscura` when SPA rendering is requested.
300
+
301
+ ### 1. Automatic SPA Detection & Rendering (`auto_render_spa`)
302
+
303
+ Similar to `auto_solve_waf`, when `auto_render_spa: true` is enabled, `HttpMimic` first sends a microsecond-fast HTTP request (Tier 1). If the response is detected as an unhydrated SPA shell (empty React `#root`, Vue `#app`, Angular `<app-root>`, Google Dynamic SERP, or `<noscript>` prompt), it automatically transitions to Obscura (Tier 2) to render the full DOM tree:
304
+
305
+ ```ruby
306
+ # Auto-detects SPA shell and seamlessly renders with Obscura
307
+ response = HttpMimic.get('https://example.com/spa', auto_render_spa: true)
308
+
309
+ puts response.code # => 200
310
+ puts response.title # => Fully hydrated DOM title
311
+ puts response.body # => Fully rendered HTML with client-side injected DOM nodes
312
+ ```
313
+
314
+ ### 2. Direct SPA Rendering
315
+
316
+ ```ruby
317
+ # Direct SPA render
318
+ response = HttpMimic.render('https://example.com/spa')
319
+
320
+ # or via alias:
321
+ response = HttpMimic.spa('https://example.com/spa')
322
+ ```
323
+
324
+ **Supported Options**:
325
+ - `:wait_until` - `'load'`, `'domcontentloaded'`, `'networkidle0'` (default: `'networkidle0'`)
326
+ - `:timeout` - execution deadline in seconds (default: 30)
327
+ - `:proxy` - proxy address (`socks5://...` or `http://...`)
328
+ - `:eval` - JavaScript expression to evaluate in page context
329
+ - `:dump` - output format: `'html'` (default), `'text'`, `'links'`, `'markdown'`, `'cookies'`
330
+ - `:stealth` - whether stealth anti-detection is enabled (default: `true`)
290
331
 
291
332
  ---
292
333
 
@@ -94,9 +94,10 @@ module HttpMimic
94
94
  args = []
95
95
  stdin_data = nil
96
96
 
97
- # Base flags: silent mode, include HTTP headers
97
+ # Base flags: silent mode, include HTTP headers, auto-decompress responses
98
98
  args << '-s'
99
99
  args << '-i'
100
+ args << '--compressed'
100
101
 
101
102
  # HTTP Method
102
103
  if @method == 'HEAD'
@@ -387,6 +388,17 @@ module HttpMimic
387
388
  headers_to_send['accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'
388
389
  headers_to_send['accept-language'] ||= 'en-US,en;q=0.9'
389
390
  end
391
+
392
+ # Smart Search referral context (e.g. searching from Google homepage)
393
+ begin
394
+ parsed_uri = URI.parse(url)
395
+ if parsed_uri && parsed_uri.host =~ /google\./i && parsed_uri.path =~ /\/search/i
396
+ headers_to_send['referer'] ||= "#{parsed_uri.scheme || 'https'}://#{parsed_uri.host}/"
397
+ headers_to_send['sec-fetch-site'] ||= 'same-origin'
398
+ end
399
+ rescue StandardError
400
+ # ignore
401
+ end
390
402
  end
391
403
 
392
404
  def find_in_download_dir(candidates)
@@ -30,6 +30,12 @@ module HttpMimic
30
30
  attr_accessor :qjs_version
31
31
  attr_accessor :qjs_github_repo
32
32
 
33
+ # Obscura headless SPA engine settings
34
+ attr_accessor :obscura_version
35
+ attr_accessor :obscura_github_repo
36
+ attr_accessor :obscura_path
37
+ attr_accessor :auto_render_spa
38
+
33
39
  # WAF & JS challenge solving settings
34
40
  attr_accessor :auto_solve_waf
35
41
 
@@ -68,6 +74,15 @@ module HttpMimic
68
74
  @qjs_version = 'v0.16.2'
69
75
  @qjs_github_repo = 'quickjs-ng/quickjs'
70
76
 
77
+ # Obscura defaults (Lightweight Rust+V8 headless SPA engine)
78
+ # Temporarily points to 'anxgang/obscura' (v0.2.3) for Web Worker importScripts support.
79
+ # Will switch back to 'h4ckf0r0day/obscura' once upstream Pull Request is merged.
80
+ # @obscura_github_repo = 'h4ckf0r0day/obscura'
81
+ @obscura_version = 'v0.2.3'
82
+ @obscura_github_repo = 'anxgang/obscura'
83
+ @obscura_path = nil
84
+ @auto_render_spa = false
85
+
71
86
  # WAF challenge solving defaults
72
87
  @auto_solve_waf = true
73
88
 
@@ -16,6 +16,9 @@ module HttpMimic
16
16
  DEFAULT_QJS_REPO = 'quickjs-ng/quickjs'
17
17
  DEFAULT_QJS_VERSION = 'v0.16.2'
18
18
 
19
+ DEFAULT_OBSCURA_REPO = 'h4ckf0r0day/obscura'
20
+ DEFAULT_OBSCURA_VERSION = 'v0.2.1'
21
+
19
22
  class DownloadError < HttpMimic::Error; end
20
23
  class UnsupportedPlatformError < HttpMimic::Error; end
21
24
 
@@ -165,6 +168,100 @@ module HttpMimic
165
168
  File.join(dir, '.qjs_version')
166
169
  end
167
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
+
168
265
  def binary_path(name, install_dir: nil)
169
266
  target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
170
267
  candidate = File.join(target_dir, binary_name_for_platform(name))
@@ -46,6 +46,11 @@ module HttpMimic
46
46
  default_options[:persist_cookies] = enabled
47
47
  end
48
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
+
49
54
  def mode(m = nil)
50
55
  return default_options[:mode] if m.nil?
51
56
  default_options[:mode] = m
@@ -93,6 +98,12 @@ module HttpMimic
93
98
  request(:options, url, options)
94
99
  end
95
100
 
101
+ def render(url, options = {})
102
+ merged = default_options.merge(options)
103
+ Obscura.render(url, merged)
104
+ end
105
+ alias spa render
106
+
96
107
  def request(method, url, options = {})
97
108
  merged = default_options.merge(options)
98
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,6 +15,12 @@ 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
 
@@ -113,6 +119,29 @@ module HttpMimic
113
119
  log_debug("[HttpMimic] Attempt #{index + 1} with #{profile} resulted in status #{response.code}. Triggering smart fallback to next profile...")
114
120
  end
115
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
+
116
145
  # Persist cookies back to store if enabled
117
146
  if should_persist_cookie && host && response && response.cookies && !response.cookies.empty?
118
147
  CookieStore.save(host, response.cookies)
@@ -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.1"
5
5
  end
@@ -9,6 +9,8 @@ module HttpMimic
9
9
 
10
10
  if akamai?(response)
11
11
  :akamai
12
+ elsif google?(response)
13
+ :google
12
14
  elsif cloudflare?(response)
13
15
  :cloudflare
14
16
  elsif datadome?(response)
@@ -24,6 +26,16 @@ module HttpMimic
24
26
  return true if body.include?('sec-if-cpt-container') || body.include?('sec-bc-button-parent')
25
27
  return true if body.include?('challenges.cloudflare.com') || body.include?('cf-turnstile')
26
28
  return true if body.include?('datadome.captcha') || body.include?('geo.captcha-delivery.com')
29
+ return true if body.include?('/httpservice/retry/enablejs') || (body.include?('knitsail') && body.include?('SG_SS'))
30
+ false
31
+ end
32
+
33
+ def google?(response)
34
+ return false unless response
35
+ body = response.body.to_s
36
+ return true if body.include?('/httpservice/retry/enablejs')
37
+ return true if body.include?('knitsail') && body.include?('SG_SS')
38
+ return true if response.headers['server']&.downcase&.include?('gws') && body.include?('enablejs')
27
39
  false
28
40
  end
29
41
 
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ module HttpMimic
7
+ module Waf
8
+ class GoogleSolver
9
+ class << self
10
+ def solve(target_url, response, options = {})
11
+ return nil unless response
12
+
13
+ html = response.body.to_s
14
+ return nil if html.empty?
15
+
16
+ uri = URI.parse(target_url) rescue nil
17
+ base_url = uri ? "#{uri.scheme}://#{uri.host}" : "https://www.google.com"
18
+
19
+ impersonate = options[:impersonate] || HttpMimic.configuration.default_impersonate || 'chrome131'
20
+ current_cookies = (response.cookies || Cookies.new).dup
21
+
22
+ # Strategy 1: Session warmup from Google root if cookies are sparse
23
+ if current_cookies.empty? || !current_cookies.key?('NID')
24
+ warmup_resp = HttpMimic.get(
25
+ "#{base_url}/",
26
+ impersonate: impersonate,
27
+ cookies: current_cookies,
28
+ auto_fallback: false,
29
+ solve_waf: false,
30
+ persist_cookies: false
31
+ )
32
+ warmup_resp.cookies.each { |k, v| current_cookies[k] = v } if warmup_resp&.cookies
33
+ end
34
+
35
+ # Strategy 2: Attempt QuickJS solve if script payload exists
36
+ scripts = html.scan(/<script[^>]*>([\s\S]*?)<\/script>/i).map(&:first)
37
+ if scripts.any? { |s| s.include?('knitsail') || s.include?('SG_SS') || s.include?('closureDynamicButton') }
38
+ sg_ss_cookie = solve_with_quickjs(target_url, scripts, current_cookies)
39
+ current_cookies['SG_SS'] = sg_ss_cookie if sg_ss_cookie && !sg_ss_cookie.start_with?('E:')
40
+ end
41
+
42
+ # Strategy 3: Retry search query with same-origin referral headers and updated cookies
43
+ retry_headers = (options[:headers] || {}).dup
44
+ retry_headers['Referer'] ||= "#{base_url}/"
45
+ retry_headers['sec-fetch-site'] ||= 'same-origin'
46
+
47
+ retry_opts = options.merge(
48
+ cookies: current_cookies,
49
+ headers: retry_headers,
50
+ auto_fallback: false,
51
+ solve_waf: false
52
+ )
53
+
54
+ HttpMimic.get(target_url, **retry_opts)
55
+ rescue StandardError => e
56
+ HttpMimic.logger.debug("[HttpMimic::Waf::GoogleSolver] Failed to solve Google challenge: #{e.message}") if HttpMimic.logger
57
+ nil
58
+ end
59
+
60
+ private
61
+
62
+ def solve_with_quickjs(target_url, scripts, cookies)
63
+ context_js_path = File.expand_path('browser_context.js', __dir__)
64
+ context_js = File.read(context_js_path)
65
+ cookie_str = cookies.respond_to?(:to_cookie_string) ? cookies.to_cookie_string : cookies.to_s
66
+
67
+ driver_script = <<~JS
68
+ globalThis.__TARGET_URL__ = #{target_url.to_json};
69
+ globalThis.__DOCUMENT_TITLE__ = "Google Search";
70
+ globalThis.__INITIAL_COOKIES__ = #{cookie_str.to_json};
71
+ globalThis.__REFERRER__ = "https://www.google.com/";
72
+
73
+ #{context_js}
74
+
75
+ // Google specific environment enhancements
76
+ globalThis.sessionStorage = {
77
+ _data: {},
78
+ getItem(k) { return this._data[k] || null; },
79
+ setItem(k, v) { this._data[k] = String(v); },
80
+ removeItem(k) { delete this._data[k]; },
81
+ clear() { this._data = {}; }
82
+ };
83
+ globalThis.localStorage = globalThis.sessionStorage;
84
+ globalThis._F_css = function() {};
85
+ globalThis.google = { c: { c: { a: true } } };
86
+
87
+ #{scripts.join("\n;\n")}
88
+
89
+ // Extract p token and invoke knitsail if present
90
+ var allScripts = #{scripts.join("\n").to_json};
91
+ var pMatch = allScripts.match(/var p=\\x27([^\\x27]+)\\x27/);
92
+ var pVal = pMatch ? pMatch[1] : null;
93
+
94
+ var solved_token = null;
95
+ if (globalThis.knitsail && typeof globalThis.knitsail.a === "function" && pVal) {
96
+ try {
97
+ globalThis.knitsail.a(pVal, function(resultCallback) {
98
+ if (typeof resultCallback === "function") {
99
+ resultCallback(function(token) {
100
+ solved_token = token;
101
+ }, [{}]);
102
+ }
103
+ }, false, undefined, undefined, undefined, undefined, true);
104
+ } catch(e) {}
105
+ }
106
+
107
+ if (typeof globalThis.__drainEventLoop === "function") {
108
+ globalThis.__drainEventLoop(50);
109
+ }
110
+
111
+ JSON.stringify({
112
+ token: solved_token
113
+ });
114
+ JS
115
+
116
+ result = JSRuntime.eval_json(driver_script)
117
+ result ? result['token'] : nil
118
+ rescue StandardError
119
+ nil
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'http_mimic/waf/detector'
4
4
  require 'http_mimic/waf/akamai_solver'
5
+ require 'http_mimic/waf/google_solver'
5
6
 
6
7
  module HttpMimic
7
8
  module Waf
@@ -13,6 +14,8 @@ module HttpMimic
13
14
  case waf_type
14
15
  when :akamai
15
16
  AkamaiSolver.solve(url, response, options)
17
+ when :google
18
+ GoogleSolver.solve(url, response, options)
16
19
  else
17
20
  nil
18
21
  end
data/lib/http_mimic.rb CHANGED
@@ -19,6 +19,8 @@ require 'http_mimic/module_methods'
19
19
  require 'http_mimic/js_runtime'
20
20
  require 'http_mimic/waf'
21
21
  require 'http_mimic/cookie_store'
22
+ require 'http_mimic/obscura'
23
+ require 'http_mimic/spa_detector'
22
24
 
23
25
  module HttpMimic
24
26
  extend ModuleMethods
@@ -94,6 +96,30 @@ module HttpMimic
94
96
  CookieStore.clear(host)
95
97
  end
96
98
 
99
+ # Obscura headless SPA engine helpers
100
+ def download_obscura!(version: nil, force: false)
101
+ Downloader.download_obscura!(version: version, force: force)
102
+ end
103
+
104
+ def obscura_installed?(version: nil)
105
+ Downloader.obscura_installed?(version: version)
106
+ end
107
+
108
+ def obscura_path
109
+ Downloader.obscura_path
110
+ end
111
+
112
+ # Render SPA page using Obscura headless engine
113
+ def render(url, options = {})
114
+ Obscura.render(url, options)
115
+ end
116
+ alias spa render
117
+
118
+ # Check if a response represents an unhydrated SPA shell
119
+ def spa?(response)
120
+ SpaDetector.spa?(response)
121
+ end
122
+
97
123
  def included(base)
98
124
  base.extend(ModuleMethods)
99
125
  base.send(:include, InstanceMethods)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http_mimic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - anxgang
@@ -72,13 +72,16 @@ files:
72
72
  - lib/http_mimic/headers.rb
73
73
  - lib/http_mimic/js_runtime.rb
74
74
  - lib/http_mimic/module_methods.rb
75
+ - lib/http_mimic/obscura.rb
75
76
  - lib/http_mimic/request.rb
76
77
  - lib/http_mimic/response.rb
77
78
  - lib/http_mimic/response_parser.rb
79
+ - lib/http_mimic/spa_detector.rb
78
80
  - lib/http_mimic/version.rb
79
81
  - lib/http_mimic/waf.rb
80
82
  - lib/http_mimic/waf/akamai_solver.rb
81
83
  - lib/http_mimic/waf/detector.rb
84
+ - lib/http_mimic/waf/google_solver.rb
82
85
  homepage: https://github.com/anxgang/http_mimic
83
86
  licenses:
84
87
  - MIT