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.
@@ -22,6 +22,7 @@ module HttpMimic
22
22
  impersonate: impersonate,
23
23
  cookies: cookies,
24
24
  headers: { 'Referer' => target_url },
25
+ proxy: options[:proxy],
25
26
  auto_fallback: false,
26
27
  solve_waf: false
27
28
  )
@@ -29,22 +30,81 @@ module HttpMimic
29
30
 
30
31
  sensor_js = script_resp.body
31
32
  context_js = File.read(CONTEXT_JS_PATH)
32
- cookie_str = cookies.to_cookie_string
33
33
 
34
34
  doc_title = initial_response.title.to_s
35
35
  user_agent = options[:user_agent]
36
36
  referer = (options[:headers] || {})['Referer'] || (options[:headers] || {})['referer'] || target_url
37
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};" : ""}
38
+ # 2. Phase 1: Virtual browser simulation inside QuickJS (initial telemetry)
39
+ driver_script_1 = build_driver_script(
40
+ target_url: target_url,
41
+ doc_title: doc_title,
42
+ cookie_str: cookies.to_cookie_string,
43
+ referer: referer,
44
+ user_agent: user_agent,
45
+ context_js: context_js,
46
+ sensor_js: sensor_js,
47
+ phase: 1
48
+ )
49
+
50
+ result1 = JSRuntime.eval_json(driver_script_1)
51
+ sensor_posts_1 = result1 && result1['sensor_posts']
52
+ return nil if sensor_posts_1.nil? || sensor_posts_1.empty?
53
+
54
+ # 3. Post Phase 1 sensor data to Akamai endpoint
55
+ updated_cookies = cookies.dup
56
+ post_sensor_data(sensor_script_url, target_url, impersonate, updated_cookies, sensor_posts_1, proxy: options[:proxy])
57
+
58
+ # 4. Phase 2: If _abck is not verified yet (~0~), perform second round after short pause
59
+ if !verified_abck?(updated_cookies)
60
+ sleep 0.8
61
+ driver_script_2 = build_driver_script(
62
+ target_url: target_url,
63
+ doc_title: doc_title,
64
+ cookie_str: updated_cookies.to_cookie_string,
65
+ referer: referer,
66
+ user_agent: user_agent,
67
+ context_js: context_js,
68
+ sensor_js: sensor_js,
69
+ phase: 2
70
+ )
71
+
72
+ result2 = JSRuntime.eval_json(driver_script_2)
73
+ sensor_posts_2 = result2 && result2['sensor_posts']
74
+ if sensor_posts_2 && !sensor_posts_2.empty?
75
+ post_sensor_data(sensor_script_url, target_url, impersonate, updated_cookies, sensor_posts_2, proxy: options[:proxy])
76
+ end
77
+ end
78
+
79
+ # 5. Retry original target URL with the updated cookies
80
+ retry_headers = (options[:headers] || {}).merge('Referer' => target_url)
81
+ HttpMimic.get(
82
+ target_url,
83
+ options.merge(
84
+ cookies: updated_cookies,
85
+ headers: retry_headers,
86
+ auto_fallback: false,
87
+ solve_waf: false
88
+ )
89
+ )
90
+ rescue StandardError => e
91
+ if HttpMimic.configuration.debug
92
+ puts "[HttpMimic::Waf::AkamaiSolver] Error solving Akamai challenge: #{e.message}"
93
+ end
94
+ nil
95
+ end
96
+
97
+ def build_driver_script(params)
98
+ <<~JS
99
+ globalThis.__TARGET_URL__ = #{params[:target_url].to_json};
100
+ globalThis.__DOCUMENT_TITLE__ = #{params[:doc_title].to_json};
101
+ globalThis.__INITIAL_COOKIES__ = #{params[:cookie_str].to_json};
102
+ globalThis.__REFERRER__ = #{params[:referer].to_json};
103
+ #{params[:user_agent] ? "globalThis.__USER_AGENT__ = #{params[:user_agent].to_json};" : ""}
104
+ globalThis.__TELEMETRY_PHASE__ = #{params[:phase] || 1};
45
105
 
46
- #{context_js}
47
- #{sensor_js}
106
+ #{params[:context_js]}
107
+ #{params[:sensor_js]}
48
108
 
49
109
  if (typeof globalThis.__simulateHumanInteractions === "function") {
50
110
  globalThis.__simulateHumanInteractions();
@@ -57,63 +117,61 @@ module HttpMimic
57
117
  sensor_posts: globalThis.__sensor_posts
58
118
  });
59
119
  JS
120
+ end
60
121
 
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
122
+ def post_sensor_data(sensor_script_url, target_url, impersonate, updated_cookies, sensor_posts, proxy: nil)
123
+ origin_url = URI.parse(target_url).tap { |u| u.path = ''; u.query = nil }.to_s rescue target_url
67
124
  sensor_posts.each do |post|
68
125
  post_body = post['body']
69
126
  next if post_body.nil? || post_body.empty?
70
127
 
128
+ post_url = if post['url'].to_s.empty?
129
+ target_url
130
+ elsif post['url'].start_with?('http')
131
+ post['url']
132
+ else
133
+ URI.join(target_url, post['url']).to_s
134
+ end
135
+
136
+ content_type = (post['headers'] && (post['headers']['Content-Type'] || post['headers']['content-type'])) || 'application/json'
137
+
71
138
  post_resp = HttpMimic.post(
72
- sensor_script_url,
139
+ post_url,
73
140
  impersonate: impersonate,
74
141
  cookies: updated_cookies,
75
142
  headers: {
76
- 'Content-Type' => 'text/plain;charset=UTF-8',
143
+ 'Content-Type' => content_type,
77
144
  'Referer' => target_url,
78
- 'Origin' => URI.parse(target_url).tap { |u| u.path = ''; u.query = nil }.to_s,
145
+ 'Origin' => origin_url,
79
146
  'Accept' => '*/*'
80
147
  },
148
+ proxy: proxy,
81
149
  body: post_body,
82
150
  auto_fallback: false,
83
151
  solve_waf: false
84
152
  )
85
153
 
86
- post_resp.cookies.each { |k, v| updated_cookies[k] = v }
154
+ post_resp.cookies.each { |k, v| updated_cookies[k] = v } if post_resp&.cookies
87
155
  end
156
+ end
88
157
 
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
158
+ def verified_abck?(cookies)
159
+ return false unless cookies
160
+ abck = cookies['_abck'] || (cookies.respond_to?(:key?) && cookies['_abck'])
161
+ abck.to_s.include?('~0~')
105
162
  end
106
163
 
107
164
  def extract_sensor_script_url(target_url, html)
108
165
  return nil if html.nil? || html.empty?
109
166
 
110
- match = html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/i)
167
+ match = html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_\-\/]+\?[^"\x27\s>]+)["\x27]/i)
111
168
  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)
169
+ match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/i)
113
170
 
114
171
  return nil unless match
115
172
 
116
- URI.join(target_url, match[1]).to_s
173
+ raw_url = match[1].gsub('&amp;', '&')
174
+ URI.join(target_url, raw_url).to_s
117
175
  rescue StandardError
118
176
  nil
119
177
  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,9 @@ 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'
24
+ require 'http_mimic/proxy_pool'
22
25
 
23
26
  module HttpMimic
24
27
  extend ModuleMethods
@@ -94,6 +97,30 @@ module HttpMimic
94
97
  CookieStore.clear(host)
95
98
  end
96
99
 
100
+ # Obscura headless SPA engine helpers
101
+ def download_obscura!(version: nil, force: false)
102
+ Downloader.download_obscura!(version: version, force: force)
103
+ end
104
+
105
+ def obscura_installed?(version: nil)
106
+ Downloader.obscura_installed?(version: version)
107
+ end
108
+
109
+ def obscura_path
110
+ Downloader.obscura_path
111
+ end
112
+
113
+ # Render SPA page using Obscura headless engine
114
+ def render(url, options = {})
115
+ Obscura.render(url, options)
116
+ end
117
+ alias spa render
118
+
119
+ # Check if a response represents an unhydrated SPA shell
120
+ def spa?(response)
121
+ SpaDetector.spa?(response)
122
+ end
123
+
97
124
  def included(base)
98
125
  base.extend(ModuleMethods)
99
126
  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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - anxgang
@@ -72,13 +72,17 @@ 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
76
+ - lib/http_mimic/proxy_pool.rb
75
77
  - lib/http_mimic/request.rb
76
78
  - lib/http_mimic/response.rb
77
79
  - lib/http_mimic/response_parser.rb
80
+ - lib/http_mimic/spa_detector.rb
78
81
  - lib/http_mimic/version.rb
79
82
  - lib/http_mimic/waf.rb
80
83
  - lib/http_mimic/waf/akamai_solver.rb
81
84
  - lib/http_mimic/waf/detector.rb
85
+ - lib/http_mimic/waf/google_solver.rb
82
86
  homepage: https://github.com/anxgang/http_mimic
83
87
  licenses:
84
88
  - MIT