http_mimic 0.5.1 → 0.5.5
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 +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +76 -33
- data/http_mimic.gemspec +1 -1
- data/lib/http_mimic/command_builder.rb +9 -3
- data/lib/http_mimic/configuration.rb +19 -1
- data/lib/http_mimic/module_methods.rb +28 -0
- data/lib/http_mimic/obscura.rb +21 -2
- data/lib/http_mimic/proxy_pool.rb +224 -0
- data/lib/http_mimic/request.rb +176 -36
- data/lib/http_mimic/response.rb +22 -3
- data/lib/http_mimic/response_parser.rb +6 -0
- data/lib/http_mimic/spa_detector.rb +1 -1
- data/lib/http_mimic/version.rb +2 -1
- data/lib/http_mimic/waf/akamai_solver.rb +252 -67
- data/lib/http_mimic/waf/browser_context.js +1297 -0
- data/lib/http_mimic.rb +1 -0
- metadata +3 -1
|
@@ -11,88 +11,86 @@ module HttpMimic
|
|
|
11
11
|
class << self
|
|
12
12
|
def solve(target_url, initial_response, options = {})
|
|
13
13
|
cookies = initial_response.cookies.dup
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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?
|
|
14
|
+
profile = options[:profile]
|
|
15
|
+
impersonate = resolve_impersonate_target(profile, options[:impersonate])
|
|
16
|
+
user_agent = resolve_user_agent(impersonate, options[:user_agent])
|
|
29
17
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
18
|
+
sensor_script_urls = extract_sensor_script_urls(target_url, initial_response.body)
|
|
19
|
+
return nil if sensor_script_urls.empty?
|
|
20
|
+
|
|
21
|
+
# 1. Fetch all dynamic sensor scripts in order
|
|
22
|
+
scripts_data = []
|
|
23
|
+
sensor_script_urls.each do |s_url|
|
|
24
|
+
s_resp = HttpMimic.get(
|
|
25
|
+
s_url,
|
|
26
|
+
impersonate: impersonate,
|
|
27
|
+
cookies: cookies,
|
|
28
|
+
headers: { 'Referer' => target_url, 'User-Agent' => user_agent },
|
|
29
|
+
proxy: options[:proxy],
|
|
30
|
+
auto_fallback: false,
|
|
31
|
+
solve_waf: false,
|
|
32
|
+
debug: options[:debug]
|
|
33
|
+
)
|
|
34
|
+
scripts_data << { url: s_url, body: s_resp.body } if s_resp.success?
|
|
35
|
+
end
|
|
36
|
+
return nil if scripts_data.empty?
|
|
33
37
|
|
|
38
|
+
context_js = File.read(CONTEXT_JS_PATH)
|
|
34
39
|
doc_title = initial_response.title.to_s
|
|
35
|
-
user_agent = options[:user_agent]
|
|
36
40
|
referer = (options[:headers] || {})['Referer'] || (options[:headers] || {})['referer'] || target_url
|
|
41
|
+
cpt_script_url = sensor_script_urls.find { |u| u.include?('t=') } || sensor_script_urls.first
|
|
37
42
|
|
|
38
|
-
# 2.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
43
|
+
# 2. Phase 1: Virtual browser simulation inside QuickJS (initial telemetry)
|
|
44
|
+
driver_script_1 = build_driver_script(
|
|
45
|
+
target_url: target_url,
|
|
46
|
+
doc_title: doc_title,
|
|
47
|
+
cookie_str: cookies.to_cookie_string,
|
|
48
|
+
referer: referer,
|
|
49
|
+
user_agent: user_agent,
|
|
50
|
+
context_js: context_js,
|
|
51
|
+
scripts_data: scripts_data,
|
|
52
|
+
phase: 1
|
|
53
|
+
)
|
|
60
54
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return nil if
|
|
55
|
+
result1 = JSRuntime.eval_json(driver_script_1)
|
|
56
|
+
sensor_posts_1 = result1 && result1['sensor_posts']
|
|
57
|
+
return nil if sensor_posts_1.nil? || sensor_posts_1.empty?
|
|
64
58
|
|
|
65
|
-
# 3. Post sensor data to Akamai endpoint
|
|
59
|
+
# 3. Post Phase 1 sensor data to Akamai endpoint
|
|
66
60
|
updated_cookies = cookies.dup
|
|
67
|
-
|
|
68
|
-
post_body = post['body']
|
|
69
|
-
next if post_body.nil? || post_body.empty?
|
|
61
|
+
post_sensor_data(cpt_script_url, target_url, impersonate, updated_cookies, sensor_posts_1, user_agent: user_agent, proxy: options[:proxy], debug: options[:debug], cpt_url: cpt_script_url)
|
|
70
62
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
solve_waf: false
|
|
63
|
+
# 4. Phase 2: If _abck is not verified yet (~0~), perform second round after short pause
|
|
64
|
+
if !verified_abck?(updated_cookies)
|
|
65
|
+
sleep 0.8
|
|
66
|
+
driver_script_2 = build_driver_script(
|
|
67
|
+
target_url: target_url,
|
|
68
|
+
doc_title: doc_title,
|
|
69
|
+
cookie_str: updated_cookies.to_cookie_string,
|
|
70
|
+
referer: referer,
|
|
71
|
+
user_agent: user_agent,
|
|
72
|
+
context_js: context_js,
|
|
73
|
+
scripts_data: scripts_data,
|
|
74
|
+
phase: 2
|
|
84
75
|
)
|
|
85
76
|
|
|
86
|
-
|
|
77
|
+
result2 = JSRuntime.eval_json(driver_script_2)
|
|
78
|
+
sensor_posts_2 = result2 && result2['sensor_posts']
|
|
79
|
+
if sensor_posts_2 && !sensor_posts_2.empty?
|
|
80
|
+
post_sensor_data(cpt_script_url, target_url, impersonate, updated_cookies, sensor_posts_2, user_agent: user_agent, proxy: options[:proxy], debug: options[:debug], cpt_url: cpt_script_url)
|
|
81
|
+
end
|
|
87
82
|
end
|
|
88
83
|
|
|
89
|
-
#
|
|
90
|
-
retry_headers = (options[:headers] || {}).merge('Referer' => target_url)
|
|
84
|
+
# 5. Retry original target URL with the updated cookies
|
|
85
|
+
retry_headers = (options[:headers] || {}).merge('Referer' => target_url, 'User-Agent' => user_agent)
|
|
91
86
|
HttpMimic.get(
|
|
92
87
|
target_url,
|
|
93
88
|
options.merge(
|
|
89
|
+
profile: profile,
|
|
90
|
+
impersonate: impersonate,
|
|
94
91
|
cookies: updated_cookies,
|
|
95
92
|
headers: retry_headers,
|
|
93
|
+
user_agent: user_agent,
|
|
96
94
|
auto_fallback: false,
|
|
97
95
|
solve_waf: false
|
|
98
96
|
)
|
|
@@ -104,19 +102,206 @@ module HttpMimic
|
|
|
104
102
|
nil
|
|
105
103
|
end
|
|
106
104
|
|
|
105
|
+
def resolve_impersonate_target(profile, explicit_imp = nil)
|
|
106
|
+
return explicit_imp.to_s if explicit_imp && !explicit_imp.empty?
|
|
107
|
+
|
|
108
|
+
case profile&.to_sym
|
|
109
|
+
when :android, :mobile
|
|
110
|
+
'chrome131_android'
|
|
111
|
+
when :ios
|
|
112
|
+
'safari260_ios'
|
|
113
|
+
when :firefox
|
|
114
|
+
'firefox135'
|
|
115
|
+
when :safari
|
|
116
|
+
'safari180'
|
|
117
|
+
else
|
|
118
|
+
HttpMimic.configuration.default_impersonate || 'chrome131'
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resolve_user_agent(impersonate, explicit_ua = nil)
|
|
123
|
+
return explicit_ua if explicit_ua && !explicit_ua.empty?
|
|
124
|
+
|
|
125
|
+
imp = impersonate.to_s.downcase
|
|
126
|
+
if imp.include?('android')
|
|
127
|
+
ver = imp[/\d+/] || '131'
|
|
128
|
+
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/#{ver}.0.0.0 Mobile Safari/537.36"
|
|
129
|
+
elsif imp.include?('ios') || imp.include?('iphone') || imp.include?('ipad')
|
|
130
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"
|
|
131
|
+
elsif imp.include?('chrome')
|
|
132
|
+
ver = imp[/\d+/] || '150'
|
|
133
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/#{ver}.0.0.0 Safari/537.36"
|
|
134
|
+
elsif imp.include?('firefox')
|
|
135
|
+
ver = imp[/\d+/] || '135'
|
|
136
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:#{ver}.0) Gecko/20100101 Firefox/#{ver}.0"
|
|
137
|
+
elsif imp.include?('safari')
|
|
138
|
+
ver = imp[/\d+/] || '18_0'
|
|
139
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/#{ver.tr('_', '.')} Safari/605.1.15"
|
|
140
|
+
else
|
|
141
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_driver_script(params)
|
|
146
|
+
scripts_code = if params[:scripts_data] && !params[:scripts_data].empty?
|
|
147
|
+
params[:scripts_data].map.with_index do |s, idx|
|
|
148
|
+
tag_var = "sTag_#{idx}"
|
|
149
|
+
<<~SCRIPT_EVAL
|
|
150
|
+
const #{tag_var} = document.createElement('script');
|
|
151
|
+
#{tag_var}.src = #{s[:url].to_json};
|
|
152
|
+
document.body.appendChild(#{tag_var});
|
|
153
|
+
document.currentScript = #{tag_var};
|
|
154
|
+
try {
|
|
155
|
+
;\n#{s[:body]}\n;
|
|
156
|
+
} catch(e) {}
|
|
157
|
+
SCRIPT_EVAL
|
|
158
|
+
end.join("\n")
|
|
159
|
+
else
|
|
160
|
+
";\n#{params[:sensor_js]}\n;"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
<<~JS
|
|
165
|
+
globalThis.__TARGET_URL__ = #{params[:target_url].to_json};
|
|
166
|
+
globalThis.__DOCUMENT_TITLE__ = #{params[:doc_title].to_json};
|
|
167
|
+
globalThis.__INITIAL_COOKIES__ = #{params[:cookie_str].to_json};
|
|
168
|
+
globalThis.__REFERRER__ = #{params[:referer].to_json};
|
|
169
|
+
#{params[:user_agent] ? "globalThis.__USER_AGENT__ = #{params[:user_agent].to_json};" : ""}
|
|
170
|
+
globalThis.__TELEMETRY_PHASE__ = #{params[:phase] || 1};
|
|
171
|
+
|
|
172
|
+
#{params[:context_js]}
|
|
173
|
+
;
|
|
174
|
+
|
|
175
|
+
#{scripts_code}
|
|
176
|
+
|
|
177
|
+
if (typeof globalThis.__simulateHumanInteractions === "function") {
|
|
178
|
+
globalThis.__simulateHumanInteractions();
|
|
179
|
+
}
|
|
180
|
+
if (typeof globalThis.__drainEventLoop === "function") {
|
|
181
|
+
globalThis.__drainEventLoop(100);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
JSON.stringify({
|
|
185
|
+
sensor_posts: globalThis.__sensor_posts
|
|
186
|
+
});
|
|
187
|
+
JS
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def post_sensor_data(sensor_script_url, target_url, impersonate, updated_cookies, sensor_posts, user_agent: nil, proxy: nil, debug: nil, cpt_url: nil)
|
|
191
|
+
origin_url = URI.parse(target_url).tap { |u| u.path = ''; u.query = nil }.to_s rescue target_url
|
|
192
|
+
sensor_posts.each do |post|
|
|
193
|
+
post_body = post['body']
|
|
194
|
+
method = (post['method'] || 'POST').to_s.upcase
|
|
195
|
+
next if method == 'POST' && (post_body.nil? || post_body.empty?)
|
|
196
|
+
|
|
197
|
+
post_url = if post['url'].to_s.empty?
|
|
198
|
+
cpt_url || target_url
|
|
199
|
+
elsif post['url'].start_with?('http')
|
|
200
|
+
u = post['url']
|
|
201
|
+
if cpt_url && !u.include?('t=') && cpt_url.include?('t=')
|
|
202
|
+
q = URI.parse(cpt_url).query rescue nil
|
|
203
|
+
u = "#{u}?#{q}" if q && !q.empty?
|
|
204
|
+
end
|
|
205
|
+
u
|
|
206
|
+
else
|
|
207
|
+
URI.join(target_url, post['url']).to_s
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
content_type = (post['headers'] && (post['headers']['Content-Type'] || post['headers']['content-type'])) || 'application/json'
|
|
212
|
+
|
|
213
|
+
post_headers = {
|
|
214
|
+
'Referer' => target_url,
|
|
215
|
+
'Origin' => origin_url,
|
|
216
|
+
'Accept' => '*/*',
|
|
217
|
+
'Sec-Fetch-Dest' => 'empty',
|
|
218
|
+
'Sec-Fetch-Mode' => 'cors',
|
|
219
|
+
'Sec-Fetch-Site' => 'same-origin'
|
|
220
|
+
}
|
|
221
|
+
if user_agent.to_s.include?('Chrome')
|
|
222
|
+
is_mobile = user_agent.to_s.include?('Mobile') || user_agent.to_s.include?('Android')
|
|
223
|
+
ver = user_agent[/Chrome\/(\d+)/, 1] || '131'
|
|
224
|
+
post_headers['sec-ch-ua'] = %("Google Chrome";v="#{ver}", "Chromium";v="#{ver}", "Not_A Brand";v="24")
|
|
225
|
+
post_headers['sec-ch-ua-mobile'] = is_mobile ? '?1' : '?0'
|
|
226
|
+
post_headers['sec-ch-ua-platform'] = is_mobile ? '"Android"' : '"macOS"'
|
|
227
|
+
end
|
|
228
|
+
post_headers['Content-Type'] = content_type if method == 'POST'
|
|
229
|
+
post_headers['User-Agent'] = user_agent if user_agent
|
|
230
|
+
|
|
231
|
+
post_resp = if method == 'GET'
|
|
232
|
+
HttpMimic.get(
|
|
233
|
+
post_url,
|
|
234
|
+
impersonate: impersonate,
|
|
235
|
+
cookies: updated_cookies,
|
|
236
|
+
headers: post_headers,
|
|
237
|
+
proxy: proxy,
|
|
238
|
+
auto_fallback: false,
|
|
239
|
+
solve_waf: false,
|
|
240
|
+
debug: debug
|
|
241
|
+
)
|
|
242
|
+
else
|
|
243
|
+
HttpMimic.post(
|
|
244
|
+
post_url,
|
|
245
|
+
impersonate: impersonate,
|
|
246
|
+
cookies: updated_cookies,
|
|
247
|
+
headers: post_headers,
|
|
248
|
+
proxy: proxy,
|
|
249
|
+
body: post_body,
|
|
250
|
+
auto_fallback: false,
|
|
251
|
+
solve_waf: false,
|
|
252
|
+
debug: debug
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
post_resp.cookies.each { |k, v| updated_cookies[k] = v } if post_resp&.cookies
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def verified_abck?(cookies)
|
|
261
|
+
return false unless cookies
|
|
262
|
+
abck = cookies['_abck'] || (cookies.respond_to?(:key?) && cookies['_abck'])
|
|
263
|
+
return true if abck.to_s.include?('~0~')
|
|
264
|
+
|
|
265
|
+
bm_sc = cookies['bm_sc'] || (cookies.respond_to?(:key?) && cookies['bm_sc'])
|
|
266
|
+
return true if bm_sc.to_s.include?('~0~0~0') && !bm_sc.to_s.start_with?('3~')
|
|
267
|
+
|
|
268
|
+
false
|
|
269
|
+
end
|
|
270
|
+
|
|
107
271
|
def extract_sensor_script_url(target_url, html)
|
|
108
272
|
return nil if html.nil? || html.empty?
|
|
109
273
|
|
|
110
|
-
match = html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_
|
|
274
|
+
match = html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_\-\/]+\?[^"\x27\s>]+)["\x27]/i)
|
|
111
275
|
match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*akam[^"\x27]*)["\x27]/i)
|
|
112
|
-
match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_
|
|
276
|
+
match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/i)
|
|
113
277
|
|
|
114
278
|
return nil unless match
|
|
115
279
|
|
|
116
|
-
|
|
280
|
+
raw_url = match[1].gsub('&', '&')
|
|
281
|
+
URI.join(target_url, raw_url).to_s
|
|
117
282
|
rescue StandardError
|
|
118
283
|
nil
|
|
119
284
|
end
|
|
285
|
+
|
|
286
|
+
def extract_sensor_script_urls(target_url, html)
|
|
287
|
+
return [] if html.nil? || html.empty?
|
|
288
|
+
|
|
289
|
+
matches = html.scan(/<script[^>]*src=["\x27]([^"\x27]+)["\x27]/i).flatten
|
|
290
|
+
akamai_scripts = matches.select do |src|
|
|
291
|
+
src =~ /\/GPv76/i || src =~ /akam/i || src =~ /\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+/i || src =~ /_bm\//i
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
if akamai_scripts.empty?
|
|
295
|
+
single = extract_sensor_script_url(target_url, html)
|
|
296
|
+
return single ? [single] : []
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
akamai_scripts.map do |raw_src|
|
|
300
|
+
URI.join(target_url, raw_src.gsub('&', '&')).to_s
|
|
301
|
+
rescue StandardError
|
|
302
|
+
nil
|
|
303
|
+
end.compact
|
|
304
|
+
end
|
|
120
305
|
end
|
|
121
306
|
end
|
|
122
307
|
end
|