http_mimic 0.3.1 → 0.4.0

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,61 @@
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 cloudflare?(response)
13
+ :cloudflare
14
+ elsif datadome?(response)
15
+ :datadome
16
+ else
17
+ nil
18
+ end
19
+ end
20
+
21
+ def challenge_page?(response)
22
+ return false unless response
23
+ body = response.body.to_s
24
+ return true if body.include?('sec-if-cpt-container') || body.include?('sec-bc-button-parent')
25
+ return true if body.include?('challenges.cloudflare.com') || body.include?('cf-turnstile')
26
+ return true if body.include?('datadome.captcha') || body.include?('geo.captcha-delivery.com')
27
+ false
28
+ end
29
+
30
+ def akamai?(response)
31
+ return true if response.headers['set-cookie']&.include?('_abck=')
32
+ return true if response.cookies.key?('_abck') || response.cookies.key?('bm_sz') || response.cookies.key?('ak_bmsc')
33
+ return true if response.headers['x-akamai-transformed'] || response.headers['x-reference-error']
34
+
35
+ body = response.body.to_s
36
+ return true if body.include?('Reference Error:') && body.include?('Akamai')
37
+ return true if body.include?('bmak') || body.include?('sensor_data')
38
+ return true if body.include?('sec-if-cpt-container') || body.include?('sec-bc-button-parent')
39
+ return true if body =~ /<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/
40
+
41
+ false
42
+ end
43
+
44
+ def cloudflare?(response)
45
+ return true if response.headers['server']&.downcase&.include?('cloudflare')
46
+ return true if response.headers['cf-ray'] || response.cookies.key?('cf_clearance')
47
+ return true if response.body.to_s.include?('challenges.cloudflare.com')
48
+
49
+ false
50
+ end
51
+
52
+ def datadome?(response)
53
+ return true if response.headers['x-datadome'] || response.cookies.key?('datadome')
54
+ return true if response.headers['server']&.downcase&.include?('datadome')
55
+
56
+ false
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http_mimic/waf/detector'
4
+ require 'http_mimic/waf/akamai_solver'
5
+
6
+ module HttpMimic
7
+ module Waf
8
+ class << self
9
+ def solve(url, response, options = {})
10
+ waf_type = Detector.detect(response)
11
+ return nil unless waf_type
12
+
13
+ case waf_type
14
+ when :akamai
15
+ AkamaiSolver.solve(url, response, options)
16
+ else
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
data/lib/http_mimic.rb CHANGED
@@ -16,6 +16,9 @@ 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'
19
22
 
20
23
  module HttpMimic
21
24
  extend ModuleMethods
@@ -43,6 +46,54 @@ module HttpMimic
43
46
  Downloader.installed?(version: version)
44
47
  end
45
48
 
49
+ # Manually download and install QuickJS binary driver
50
+ def download_qjs!(version: nil, force: false)
51
+ Downloader.download_qjs!(version: version, force: force)
52
+ end
53
+
54
+ # Check if QuickJS driver is installed locally
55
+ def qjs_installed?(version: nil)
56
+ Downloader.qjs_installed?(version: version)
57
+ end
58
+
59
+ # Return local path to QuickJS binary
60
+ def qjs_path
61
+ Downloader.qjs_path
62
+ end
63
+
64
+ # Evaluate JavaScript code using QuickJS
65
+ def eval_js(code, options = {})
66
+ JSRuntime.eval(code, timeout: options[:timeout], install_dir: options[:install_dir], auto_download: options[:auto_download])
67
+ end
68
+
69
+ # Evaluate JavaScript and auto-parse JSON result
70
+ def eval_js_json(code, options = {})
71
+ JSRuntime.eval_json(code, timeout: options[:timeout], install_dir: options[:install_dir], auto_download: options[:auto_download])
72
+ end
73
+
74
+ # Automatically detect WAF type from response
75
+ def detect_waf(response)
76
+ Waf::Detector.detect(response)
77
+ end
78
+
79
+ # Attempt to solve WAF challenge for a blocked response
80
+ def solve_waf(url, response, options = {})
81
+ Waf.solve(url, response, options)
82
+ end
83
+
84
+ # Persistent Host CookieStore helpers
85
+ def load_cookies(host, max_age: nil)
86
+ CookieStore.load(host, max_age: max_age)
87
+ end
88
+
89
+ def save_cookies(host, cookies, ttl: nil)
90
+ CookieStore.save(host, cookies, ttl: ttl || CookieStore::DEFAULT_TTL)
91
+ end
92
+
93
+ def clear_cookies!(host = nil)
94
+ CookieStore.clear(host)
95
+ end
96
+
46
97
  def included(base)
47
98
  base.extend(ModuleMethods)
48
99
  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.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - anxgang
@@ -65,15 +65,20 @@ 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
73
75
  - lib/http_mimic/request.rb
74
76
  - lib/http_mimic/response.rb
75
77
  - lib/http_mimic/response_parser.rb
76
78
  - lib/http_mimic/version.rb
79
+ - lib/http_mimic/waf.rb
80
+ - lib/http_mimic/waf/akamai_solver.rb
81
+ - lib/http_mimic/waf/detector.rb
77
82
  homepage: https://github.com/anxgang/http_mimic
78
83
  licenses:
79
84
  - MIT