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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'json'
5
+
6
+ module HttpMimic
7
+ module Waf
8
+ class AkamaiSolver
9
+ CONTEXT_JS_PATH = File.expand_path('browser_context.js', __dir__)
10
+
11
+ class << self
12
+ def solve(target_url, initial_response, options = {})
13
+ cookies = initial_response.cookies.dup
14
+ impersonate = options[:impersonate] || HttpMimic.configuration.default_impersonate || 'chrome131'
15
+
16
+ sensor_script_url = extract_sensor_script_url(target_url, initial_response.body)
17
+ return nil unless sensor_script_url
18
+
19
+ # 1. Fetch the dynamic sensor script
20
+ script_resp = HttpMimic.get(
21
+ sensor_script_url,
22
+ impersonate: impersonate,
23
+ cookies: cookies,
24
+ headers: { 'Referer' => target_url },
25
+ auto_fallback: false,
26
+ solve_waf: false
27
+ )
28
+ return nil unless script_resp.success?
29
+
30
+ sensor_js = script_resp.body
31
+ context_js = File.read(CONTEXT_JS_PATH)
32
+ cookie_str = cookies.to_cookie_string
33
+
34
+ doc_title = initial_response.title.to_s
35
+ user_agent = options[:user_agent]
36
+ referer = (options[:headers] || {})['Referer'] || (options[:headers] || {})['referer'] || target_url
37
+
38
+ # 2. Run virtual browser simulation inside QuickJS
39
+ driver_script = <<~JS
40
+ globalThis.__TARGET_URL__ = #{target_url.to_json};
41
+ globalThis.__DOCUMENT_TITLE__ = #{doc_title.to_json};
42
+ globalThis.__INITIAL_COOKIES__ = #{cookie_str.to_json};
43
+ globalThis.__REFERRER__ = #{referer.to_json};
44
+ #{user_agent ? "globalThis.__USER_AGENT__ = #{user_agent.to_json};" : ""}
45
+
46
+ #{context_js}
47
+ #{sensor_js}
48
+
49
+ if (typeof globalThis.__simulateHumanInteractions === "function") {
50
+ globalThis.__simulateHumanInteractions();
51
+ }
52
+ if (typeof globalThis.__drainEventLoop === "function") {
53
+ globalThis.__drainEventLoop(100);
54
+ }
55
+
56
+ JSON.stringify({
57
+ sensor_posts: globalThis.__sensor_posts
58
+ });
59
+ JS
60
+
61
+ result = JSRuntime.eval_json(driver_script)
62
+ sensor_posts = result && result['sensor_posts']
63
+ return nil if sensor_posts.nil? || sensor_posts.empty?
64
+
65
+ # 3. Post sensor data to Akamai endpoint
66
+ updated_cookies = cookies.dup
67
+ sensor_posts.each do |post|
68
+ post_body = post['body']
69
+ next if post_body.nil? || post_body.empty?
70
+
71
+ post_resp = HttpMimic.post(
72
+ sensor_script_url,
73
+ impersonate: impersonate,
74
+ cookies: updated_cookies,
75
+ headers: {
76
+ 'Content-Type' => 'text/plain;charset=UTF-8',
77
+ 'Referer' => target_url,
78
+ 'Origin' => URI.parse(target_url).tap { |u| u.path = ''; u.query = nil }.to_s,
79
+ 'Accept' => '*/*'
80
+ },
81
+ body: post_body,
82
+ auto_fallback: false,
83
+ solve_waf: false
84
+ )
85
+
86
+ post_resp.cookies.each { |k, v| updated_cookies[k] = v }
87
+ end
88
+
89
+ # 4. Retry original target URL with the updated cookies
90
+ retry_headers = (options[:headers] || {}).merge('Referer' => target_url)
91
+ HttpMimic.get(
92
+ target_url,
93
+ options.merge(
94
+ cookies: updated_cookies,
95
+ headers: retry_headers,
96
+ auto_fallback: false,
97
+ solve_waf: false
98
+ )
99
+ )
100
+ rescue StandardError => e
101
+ if HttpMimic.configuration.debug
102
+ puts "[HttpMimic::Waf::AkamaiSolver] Error solving Akamai challenge: #{e.message}"
103
+ end
104
+ nil
105
+ end
106
+
107
+ def extract_sensor_script_url(target_url, html)
108
+ return nil if html.nil? || html.empty?
109
+
110
+ match = html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/i)
111
+ match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*akam[^"\x27]*)["\x27]/i)
112
+ match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_\-\/]+\?v=[a-zA-Z0-9_-]+)["\x27]/i)
113
+
114
+ return nil unless match
115
+
116
+ URI.join(target_url, match[1]).to_s
117
+ rescue StandardError
118
+ nil
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpMimic
4
+ module Waf
5
+ class Detector
6
+ class << self
7
+ def detect(response)
8
+ return nil unless response
9
+
10
+ if akamai?(response)
11
+ :akamai
12
+ elsif google?(response)
13
+ :google
14
+ elsif cloudflare?(response)
15
+ :cloudflare
16
+ elsif datadome?(response)
17
+ :datadome
18
+ else
19
+ nil
20
+ end
21
+ end
22
+
23
+ def challenge_page?(response)
24
+ return false unless response
25
+ body = response.body.to_s
26
+ return true if body.include?('sec-if-cpt-container') || body.include?('sec-bc-button-parent')
27
+ return true if body.include?('challenges.cloudflare.com') || body.include?('cf-turnstile')
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')
39
+ false
40
+ end
41
+
42
+ def akamai?(response)
43
+ return true if response.headers['set-cookie']&.include?('_abck=')
44
+ return true if response.cookies.key?('_abck') || response.cookies.key?('bm_sz') || response.cookies.key?('ak_bmsc')
45
+ return true if response.headers['x-akamai-transformed'] || response.headers['x-reference-error']
46
+
47
+ body = response.body.to_s
48
+ return true if body.include?('Reference Error:') && body.include?('Akamai')
49
+ return true if body.include?('bmak') || body.include?('sensor_data')
50
+ return true if body.include?('sec-if-cpt-container') || body.include?('sec-bc-button-parent')
51
+ return true if body =~ /<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/
52
+
53
+ false
54
+ end
55
+
56
+ def cloudflare?(response)
57
+ return true if response.headers['server']&.downcase&.include?('cloudflare')
58
+ return true if response.headers['cf-ray'] || response.cookies.key?('cf_clearance')
59
+ return true if response.body.to_s.include?('challenges.cloudflare.com')
60
+
61
+ false
62
+ end
63
+
64
+ def datadome?(response)
65
+ return true if response.headers['x-datadome'] || response.cookies.key?('datadome')
66
+ return true if response.headers['server']&.downcase&.include?('datadome')
67
+
68
+ false
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -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
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http_mimic/waf/detector'
4
+ require 'http_mimic/waf/akamai_solver'
5
+ require 'http_mimic/waf/google_solver'
6
+
7
+ module HttpMimic
8
+ module Waf
9
+ class << self
10
+ def solve(url, response, options = {})
11
+ waf_type = Detector.detect(response)
12
+ return nil unless waf_type
13
+
14
+ case waf_type
15
+ when :akamai
16
+ AkamaiSolver.solve(url, response, options)
17
+ when :google
18
+ GoogleSolver.solve(url, response, options)
19
+ else
20
+ nil
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
data/lib/http_mimic.rb CHANGED
@@ -16,6 +16,11 @@ require 'http_mimic/command_builder'
16
16
  require 'http_mimic/request'
17
17
  require 'http_mimic/client'
18
18
  require 'http_mimic/module_methods'
19
+ require 'http_mimic/js_runtime'
20
+ require 'http_mimic/waf'
21
+ require 'http_mimic/cookie_store'
22
+ require 'http_mimic/obscura'
23
+ require 'http_mimic/spa_detector'
19
24
 
20
25
  module HttpMimic
21
26
  extend ModuleMethods
@@ -43,6 +48,78 @@ module HttpMimic
43
48
  Downloader.installed?(version: version)
44
49
  end
45
50
 
51
+ # Manually download and install QuickJS binary driver
52
+ def download_qjs!(version: nil, force: false)
53
+ Downloader.download_qjs!(version: version, force: force)
54
+ end
55
+
56
+ # Check if QuickJS driver is installed locally
57
+ def qjs_installed?(version: nil)
58
+ Downloader.qjs_installed?(version: version)
59
+ end
60
+
61
+ # Return local path to QuickJS binary
62
+ def qjs_path
63
+ Downloader.qjs_path
64
+ end
65
+
66
+ # Evaluate JavaScript code using QuickJS
67
+ def eval_js(code, options = {})
68
+ JSRuntime.eval(code, timeout: options[:timeout], install_dir: options[:install_dir], auto_download: options[:auto_download])
69
+ end
70
+
71
+ # Evaluate JavaScript and auto-parse JSON result
72
+ def eval_js_json(code, options = {})
73
+ JSRuntime.eval_json(code, timeout: options[:timeout], install_dir: options[:install_dir], auto_download: options[:auto_download])
74
+ end
75
+
76
+ # Automatically detect WAF type from response
77
+ def detect_waf(response)
78
+ Waf::Detector.detect(response)
79
+ end
80
+
81
+ # Attempt to solve WAF challenge for a blocked response
82
+ def solve_waf(url, response, options = {})
83
+ Waf.solve(url, response, options)
84
+ end
85
+
86
+ # Persistent Host CookieStore helpers
87
+ def load_cookies(host, max_age: nil)
88
+ CookieStore.load(host, max_age: max_age)
89
+ end
90
+
91
+ def save_cookies(host, cookies, ttl: nil)
92
+ CookieStore.save(host, cookies, ttl: ttl || CookieStore::DEFAULT_TTL)
93
+ end
94
+
95
+ def clear_cookies!(host = nil)
96
+ CookieStore.clear(host)
97
+ end
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
+
46
123
  def included(base)
47
124
  base.extend(ModuleMethods)
48
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.3.2
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - anxgang
@@ -65,15 +65,23 @@ files:
65
65
  - lib/http_mimic/client.rb
66
66
  - lib/http_mimic/command_builder.rb
67
67
  - lib/http_mimic/configuration.rb
68
+ - lib/http_mimic/cookie_store.rb
68
69
  - lib/http_mimic/cookies.rb
69
70
  - lib/http_mimic/downloader.rb
70
71
  - lib/http_mimic/exceptions.rb
71
72
  - lib/http_mimic/headers.rb
73
+ - lib/http_mimic/js_runtime.rb
72
74
  - lib/http_mimic/module_methods.rb
75
+ - lib/http_mimic/obscura.rb
73
76
  - lib/http_mimic/request.rb
74
77
  - lib/http_mimic/response.rb
75
78
  - lib/http_mimic/response_parser.rb
79
+ - lib/http_mimic/spa_detector.rb
76
80
  - lib/http_mimic/version.rb
81
+ - lib/http_mimic/waf.rb
82
+ - lib/http_mimic/waf/akamai_solver.rb
83
+ - lib/http_mimic/waf/detector.rb
84
+ - lib/http_mimic/waf/google_solver.rb
77
85
  homepage: https://github.com/anxgang/http_mimic
78
86
  licenses:
79
87
  - MIT